支持素材生成上传参考图

图标素材生成支持上传普通参考图并提交到后端参考图数组。

UI设计图生成和UI素材提取支持上传参考图并写入生成输入记录。

同步前后端契约、OpenAPI、文档和相关单测。
This commit is contained in:
2026-06-27 20:27:55 +08:00
parent f20f4e0f8e
commit 5976c2b337
31 changed files with 1232 additions and 235 deletions
@@ -184,6 +184,8 @@ function createStageProps(): ImageCanvasStageViewProps {
onUiAssetExtractionPointerStart: vi.fn(),
onUiAssetExtractionPointerMove: vi.fn(),
onUiAssetExtractionPointerEnd: vi.fn(),
onRequestUiAssetExtractionReferenceUpload: vi.fn(),
onRemoveUiAssetExtractionReference: vi.fn(),
onSubmitUiAssetExtraction: vi.fn(),
onQuickEditSelectionToolChange: vi.fn(),
onQuickEditSelectionPointerStart: vi.fn(),
@@ -297,6 +299,7 @@ describe('ImageCanvasEditorShellView', () => {
tool: 'rect' as const,
model: 'gemini-3.1-flash-image-preview',
marks: [],
references: [],
draftMark: null,
status: 'idle' as const,
},
@@ -342,6 +342,7 @@ export type CharacterAnimationPanelState = {
export type UploadTarget =
| 'asset'
| 'generation-reference'
| 'ui-asset-extraction-reference'
| 'quick-edit-reference'
| 'video-reference-image'
| 'video-reference-video'
@@ -43,6 +43,7 @@ import type {
CanvasLayer,
CanvasTool,
CanvasViewport,
CharacterReferenceImage,
ImageContextMenuState,
QuickEditPanelState,
} from './ImageCanvasEditorTypes';
@@ -164,6 +165,9 @@ export function ImageCanvasEditorView() {
const setQuickEditPanelRef = useRef<
Dispatch<SetStateAction<QuickEditPanelState | null>>
>(() => {});
const appendUiAssetExtractionReferencesRef = useRef<
(references: CharacterReferenceImage[]) => void
>(() => {});
const deleteLayerByIdRef = useRef<(targetLayerId: string | null) => void>(
() => {},
);
@@ -312,7 +316,9 @@ export function ImageCanvasEditorView() {
if (!isMounted || currentRequestId !== requestId) {
return;
}
setWalletBalanceLabel(`${formatDashboardCount(dashboard.walletBalance)}泥点`);
setWalletBalanceLabel(
`${formatDashboardCount(dashboard.walletBalance)}泥点`,
);
})
.catch(() => {
if (!isMounted || currentRequestId !== requestId) {
@@ -790,6 +796,8 @@ export function ImageCanvasEditorView() {
setLayers,
setGenerateDialog,
setQuickEditPanel: (updater) => setQuickEditPanelRef.current(updater),
appendUiAssetExtractionReferences: (references) =>
appendUiAssetExtractionReferencesRef.current(references),
appendCanvasLayersWithResources,
selectSingleLayer,
});
@@ -889,12 +897,16 @@ export function ImageCanvasEditorView() {
endUiAssetExtractionPointer,
endQuickEditSelectionPointer,
cancelUiAssetExtraction,
appendUiAssetExtractionReferences,
removeUiAssetExtractionReference,
submitUiAssetExtraction,
generationComposerStyle,
generationComposerNode,
switchGenerationTool,
} = generationSurface;
setQuickEditPanelRef.current = setQuickEditPanel;
appendUiAssetExtractionReferencesRef.current =
appendUiAssetExtractionReferences;
const clearGenerationPanelsAfterBlur = useCallback(() => {
setQuickEditPanel(null);
setCropExpandPanel(null);
@@ -927,7 +939,7 @@ export function ImageCanvasEditorView() {
});
const contextMenuLayer =
contextMenu?.kind === 'layer'
? layers.find((layer) => layer.id === contextMenu.layerId) ?? null
? (layers.find((layer) => layer.id === contextMenu.layerId) ?? null)
: null;
const {
canvasClipboard,
@@ -1078,7 +1090,9 @@ export function ImageCanvasEditorView() {
}, [pendingGenerationDeleteDialog, removeCanvasGenerationDialog]);
const updateLayerAssetKind = useCallback(
(layerId: string, assetKind: CanvasAssetKind | null) => {
const targetLayer = layersRef.current.find((layer) => layer.id === layerId);
const targetLayer = layersRef.current.find(
(layer) => layer.id === layerId,
);
if (!targetLayer || targetLayer.assetKind === assetKind) {
return;
}
@@ -1490,8 +1504,7 @@ export function ImageCanvasEditorView() {
onUpdateLayerAssetKind: updateLayerAssetKind,
onGenerationFramePointerDown: handleGenerationFramePointerDown,
onActivateGenerationDialog: activateCanvasGenerationDialog,
onCropExpandHandlePointerDown:
generationSurface.startCropExpandFrameResize,
onCropExpandHandlePointerDown: generationSurface.startCropExpandFrameResize,
onOpenQuickEditPanel: openQuickEditPanel,
onOpenRedrawPanel: openRedrawPanel,
onOpenCropExpandPanel: openCropExpandPanel,
@@ -1502,6 +1515,9 @@ export function ImageCanvasEditorView() {
onUiAssetExtractionPointerStart: startUiAssetExtractionPointer,
onUiAssetExtractionPointerMove: moveUiAssetExtractionPointer,
onUiAssetExtractionPointerEnd: endUiAssetExtractionPointer,
onRequestUiAssetExtractionReferenceUpload: () =>
requestUpload('ui-asset-extraction-reference'),
onRemoveUiAssetExtractionReference: removeUiAssetExtractionReference,
onSubmitUiAssetExtraction: () => void submitUiAssetExtraction(),
contextMenuLayer,
onQuickEditSelectionToolChange: changeQuickEditSelectionTool,
@@ -101,7 +101,9 @@ export function formatIconDescriptionsText(descriptions: string[]): string {
function isSeedanceVideoModel(model: string | undefined) {
const resolvedModel = model ?? DEFAULT_VIDEO_MODEL;
return resolvedModel === 'seedance2.0' || resolvedModel === 'seedance2.0-fast';
return (
resolvedModel === 'seedance2.0' || resolvedModel === 'seedance2.0-fast'
);
}
function getReferenceMediaType(layer: CanvasLayer) {
@@ -301,6 +303,7 @@ export function createIconGenerationDialogDraft({
status: 'idle',
composerOpen: true,
iconSpecReference: null,
generationReferences: [],
iconDescriptions: [],
imageModel: normalizedImageModel,
aspectRatio: dimensionDefaults.aspectRatio,
@@ -501,9 +504,7 @@ function resolveUserGenerationPromptSnapshot(sourceLayer: CanvasLayer) {
return structuredLines.join('\n');
}
function resolveAudioRedrawPrompt(
sourceLayer: CanvasLayer,
) {
function resolveAudioRedrawPrompt(sourceLayer: CanvasLayer) {
return resolveUserGenerationPromptSnapshot(sourceLayer);
}
@@ -525,8 +526,9 @@ function resolveAudioRedrawMode(sourceLayer: CanvasLayer) {
function resolveAudioRedrawSoundModel(
sourceLayer: CanvasLayer,
): NonNullable<GenerateDialogState['soundModel']> {
const fieldValue = findGenerationInputFieldValue(sourceLayer, ['model'])
?.trim();
const fieldValue = findGenerationInputFieldValue(sourceLayer, [
'model',
])?.trim();
// 中文注释:编辑器音效改造入口当前只暴露 Vidu audio1.0,历史快照里的其他模型统一回落到默认模型。
return fieldValue === DEFAULT_SOUND_EFFECT_MODEL ? 'audio1.0' : 'audio1.0';
}
@@ -578,7 +580,6 @@ export function createAudioRedrawGenerationDialogDraft(
};
}
export function createVideoRedrawGenerationDialogDraft(
sourceLayer: CanvasLayer,
): Omit<CanvasGenerationDialogState, 'id'> | null {
@@ -671,10 +672,9 @@ function buildSourceSidePlaceholder(sourceLayer: CanvasLayer) {
};
}
function placeDraftBesideSourceLayer<T extends Omit<CanvasGenerationDialogState, 'id'>>(
draft: T,
sourceLayer: CanvasLayer,
): T {
function placeDraftBesideSourceLayer<
T extends Omit<CanvasGenerationDialogState, 'id'>,
>(draft: T, sourceLayer: CanvasLayer): T {
if (!draft.placeholder) {
return draft;
}
@@ -719,7 +719,10 @@ function resolveSpecTypeFromSourceLayer(
if (sourceDialog?.mode === 'spec' && sourceDialog.specType) {
return sourceDialog.specType;
}
if (sourceLayer.assetKind === 'icon-spec' || isIconSpecReferenceLayer(sourceLayer)) {
if (
sourceLayer.assetKind === 'icon-spec' ||
isIconSpecReferenceLayer(sourceLayer)
) {
return 'icon';
}
const fields = getGenerationInputFieldValues(sourceLayer);
@@ -773,7 +776,10 @@ function resolvePublicationWorkflowIdFromLayer(
sourceLayer: CanvasLayer,
sourceDialog?: CanvasGenerationDialogState | null,
): PublicationMaterialsWorkflowId {
if (sourceDialog?.mode === 'publication' && sourceDialog.publicationWorkflowId) {
if (
sourceDialog?.mode === 'publication' &&
sourceDialog.publicationWorkflowId
) {
return sourceDialog.publicationWorkflowId;
}
const ratio = inferEditorImageAspectRatio(
@@ -833,7 +839,9 @@ function restoreIconDescriptionsFromLayer(
iconDescriptions: descriptions.length
? descriptions
: (sourceDialog?.iconDescriptions ?? draft.iconDescriptions),
iconSpecReference: sourceDialog?.iconSpecReference ?? draft.iconSpecReference,
iconSpecReference:
sourceDialog?.iconSpecReference ?? draft.iconSpecReference,
generationReferences: sourceDialog?.generationReferences ?? [],
};
}
@@ -843,7 +851,10 @@ function createSameSourceCharacterAnimationDraft({
viewport,
sourceAnimationLayer,
mode,
}: SourceGenerationDialogDraftContext): Omit<CanvasGenerationDialogState, 'id'> | null {
}: SourceGenerationDialogDraftContext): Omit<
CanvasGenerationDialogState,
'id'
> | null {
const animationSourceLayer = sourceAnimationLayer ?? sourceLayer;
const draft = createCharacterAnimationGenerationDialogDraft({
canvasSize,
@@ -871,7 +882,10 @@ export function createSameSourceGenerationDialogDraft({
sourceDialog,
mode,
sourceAnimationLayer,
}: SourceGenerationDialogDraftContext): Omit<CanvasGenerationDialogState, 'id'> | null {
}: SourceGenerationDialogDraftContext): Omit<
CanvasGenerationDialogState,
'id'
> | null {
const sourceMode = resolveGeneratedSourceDialogMode({
sourceLayer,
sourceDialog,
@@ -919,7 +933,10 @@ export function createSameSourceGenerationDialogDraft({
...createCharacterGenerationDialogDraft({
canvasSize,
viewport,
imageModel: sourceDialog?.imageModel ?? sourceLayer.model ?? DEFAULT_IMAGE_MODEL,
imageModel:
sourceDialog?.imageModel ??
sourceLayer.model ??
DEFAULT_IMAGE_MODEL,
}),
prompt,
sourceLayerId: sourceLayer.id,
@@ -994,7 +1011,9 @@ export function createSameSourceGenerationDialogDraft({
canvasSize,
viewport,
imageModel:
sourceDialog?.imageModel ?? sourceLayer.model ?? DEFAULT_IMAGE_MODEL,
sourceDialog?.imageModel ??
sourceLayer.model ??
DEFAULT_IMAGE_MODEL,
}),
sourceLayerId: sourceLayer.id,
},
@@ -1080,10 +1099,16 @@ export function createEditDialogDraft(
);
const aspectRatio =
options.aspectRatio ??
inferEditorImageAspectRatio(sourceLayer.originalWidth, sourceLayer.originalHeight);
inferEditorImageAspectRatio(
sourceLayer.originalWidth,
sourceLayer.originalHeight,
);
const imageSize =
options.imageSize ??
inferEditorImageSizeLabel(sourceLayer.originalWidth, sourceLayer.originalHeight);
inferEditorImageSizeLabel(
sourceLayer.originalWidth,
sourceLayer.originalHeight,
);
return {
mode: 'edit',
prompt: resolveUserGenerationPromptSnapshot(sourceLayer),
@@ -1104,12 +1129,14 @@ export function createQuickEditPanelDraft(
imageSize?: string;
} = {},
): QuickEditPanelState {
const aspectRatio = options.aspectRatio ??
const aspectRatio =
options.aspectRatio ??
inferEditorImageAspectRatio(
sourceLayer.originalWidth,
sourceLayer.originalHeight,
);
const imageSize = options.imageSize ??
const imageSize =
options.imageSize ??
inferEditorImageSizeLabel(
sourceLayer.originalWidth,
sourceLayer.originalHeight,
@@ -1258,7 +1285,8 @@ export function appendCharacterReference(
dialog: GenerateDialogState | null,
layer: CanvasLayer,
): GenerateDialogState | null {
return dialog?.mode === 'character' && getReferenceMediaType(layer) === 'image'
return dialog?.mode === 'character' &&
getReferenceMediaType(layer) === 'image'
? {
...resetFailedGenerationDialog(dialog),
characterReferences: [
@@ -1287,7 +1315,12 @@ export function appendGenerationReference(
if (dialog?.mode === 'video' && !isSeedanceVideoModel(dialog.videoModel)) {
return dialog;
}
if (dialog?.mode === 'generate' || dialog?.mode === 'quick-edit') {
if (
dialog?.mode === 'generate' ||
dialog?.mode === 'quick-edit' ||
dialog?.mode === 'icon' ||
dialog?.mode === 'ui-design'
) {
if (getReferenceMediaType(layer) !== 'image') {
return dialog;
}
@@ -1316,7 +1349,8 @@ export function appendPublicationReference(
dialog: GenerateDialogState | null,
layer: CanvasLayer,
): GenerateDialogState | null {
return dialog?.mode === 'publication' && getReferenceMediaType(layer) === 'image'
return dialog?.mode === 'publication' &&
getReferenceMediaType(layer) === 'image'
? {
...resetFailedGenerationDialog(dialog),
publicationReferences: [
@@ -1396,7 +1430,11 @@ export function appendQuickEditReference(
panel: QuickEditPanelState | null,
layer: CanvasLayer,
): QuickEditPanelState | null {
if (!panel || panel.mode === 'redraw' || getReferenceMediaType(layer) !== 'image') {
if (
!panel ||
panel.mode === 'redraw' ||
getReferenceMediaType(layer) !== 'image'
) {
return panel;
}
return {
@@ -17,6 +17,7 @@ import {
buildSoundEffectGenerationInputs,
buildSpecGenerationInputs,
buildSpecPrompt,
buildUiDesignAssetExtractionGenerationInputs,
calculateCharacterAnimationPrice,
calculateEditorBackgroundMusicPrice,
calculateEditorIconSpritesheetPrice,
@@ -73,17 +74,27 @@ describe('ImageCanvasGenerationModel', () => {
(model) => !(model in EDITOR_MODEL_MUD_POINT_CONFIG),
),
).toEqual([]);
expect(EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]['1K']).toBe(3);
expect(EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]['2K']).toBe(5);
expect(EDITOR_MODEL_MUD_POINT_CONFIG[CHARACTER_ANIMATION_MODEL]).toBeTruthy();
expect(
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]['1K'],
).toBe(3);
expect(
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]['2K'],
).toBe(5);
expect(
EDITOR_MODEL_MUD_POINT_CONFIG[CHARACTER_ANIMATION_MODEL],
).toBeTruthy();
expect(
EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG[DEFAULT_SOUND_EFFECT_MODEL],
).toBe(5);
expect(
EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG[BACKGROUND_MUSIC_MODEL_SUNO],
EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG[
BACKGROUND_MUSIC_MODEL_SUNO
],
).toBe(12);
// 中文注释:Veo 已从前端入口移除,但客户端类型和后端兼容层仍保留,定价表也必须覆盖,避免旧布局回放或测试请求价格漂移。
expect(EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_VEO_3_1]).toBeTruthy();
expect(
EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_VEO_3_1],
).toBeTruthy();
expect(
EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_VEO_3_1_FAST],
).toBeTruthy();
@@ -93,8 +104,12 @@ describe('ImageCanvasGenerationModel', () => {
expect(calculateEditorImageModelPrice(DEFAULT_IMAGE_MODEL)).toBe(
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[DEFAULT_IMAGE_MODEL]['1K'],
);
expect(calculateEditorImageModelPrice(IMAGE_MODEL_GPT_IMAGE_2, '1K')).toBe(3);
expect(calculateEditorImageModelPrice(IMAGE_MODEL_GPT_IMAGE_2, '2K')).toBe(5);
expect(calculateEditorImageModelPrice(IMAGE_MODEL_GPT_IMAGE_2, '1K')).toBe(
3,
);
expect(calculateEditorImageModelPrice(IMAGE_MODEL_GPT_IMAGE_2, '2K')).toBe(
5,
);
expect(
calculateEditorImageGenerationPrice({
kind: 'character',
@@ -120,21 +135,25 @@ describe('ImageCanvasGenerationModel', () => {
expect(calculateEditorVideoPrice(VIDEO_MODEL_KLING_3, '480p', 4)).toBe(
EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_KLING_3]['480p'] * 4,
);
expect(calculateEditorVideoPrice(VIDEO_MODEL_KLING_3, '720p', 5)).toBe(
150,
);
expect(calculateEditorVideoPrice(VIDEO_MODEL_KLING_3, '720p', 5)).toBe(150);
expect(calculateEditorVideoPrice(VIDEO_MODEL_KLING_3_OMNI, '720p', 5)).toBe(
200,
);
expect(calculateCharacterAnimationPrice(CHARACTER_ANIMATION_MODEL, '720p', 6))
.toBe(
EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[CHARACTER_ANIMATION_MODEL]['720p'] * 6,
);
expect(
calculateCharacterAnimationPrice(CHARACTER_ANIMATION_MODEL, '720p', 6),
).toBe(
EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[CHARACTER_ANIMATION_MODEL]['720p'] *
6,
);
expect(calculateEditorSoundEffectPrice(DEFAULT_SOUND_EFFECT_MODEL)).toBe(
EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG[DEFAULT_SOUND_EFFECT_MODEL],
);
expect(calculateEditorBackgroundMusicPrice(BACKGROUND_MUSIC_MODEL_SUNO)).toBe(
EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG[BACKGROUND_MUSIC_MODEL_SUNO],
expect(
calculateEditorBackgroundMusicPrice(BACKGROUND_MUSIC_MODEL_SUNO),
).toBe(
EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG[
BACKGROUND_MUSIC_MODEL_SUNO
],
);
});
@@ -167,15 +186,21 @@ describe('ImageCanvasGenerationModel', () => {
},
});
expect(calculateEditorImageModelPrice(IMAGE_MODEL_GPT_IMAGE_2, '1K')).toBe(29);
expect(calculateEditorSpecGenerationPrice(IMAGE_MODEL_GPT_IMAGE_2)).toBe(58);
expect(calculateEditorImageModelPrice(IMAGE_MODEL_GPT_IMAGE_2, '1K')).toBe(
29,
);
expect(calculateEditorSpecGenerationPrice(IMAGE_MODEL_GPT_IMAGE_2)).toBe(
58,
);
expect(calculateEditorVideoPrice(VIDEO_MODEL_SEEDANCE_2, '720p', 5)).toBe(
130,
);
expect(calculateEditorSoundEffectPrice(DEFAULT_SOUND_EFFECT_MODEL)).toBe(15);
expect(calculateEditorBackgroundMusicPrice(BACKGROUND_MUSIC_MODEL_SUNO)).toBe(
9,
expect(calculateEditorSoundEffectPrice(DEFAULT_SOUND_EFFECT_MODEL)).toBe(
15,
);
expect(
calculateEditorBackgroundMusicPrice(BACKGROUND_MUSIC_MODEL_SUNO),
).toBe(9);
});
it('builds user-facing generation input snapshots instead of backend prompts', () => {
@@ -250,9 +275,7 @@ describe('ImageCanvasGenerationModel', () => {
resourceId: 'resource-icon-spec',
}),
).toEqual({
fields: [
{ title: '素材描述', value: '返回按钮\n设置按钮' },
],
fields: [{ title: '素材描述', value: '返回按钮\n设置按钮' }],
references: [
{
title: '图标规范',
@@ -277,14 +300,51 @@ describe('ImageCanvasGenerationModel', () => {
],
});
expect(
buildQuickEditGenerationInputs('快速编辑提示词', '让图2下雨', sourceLayer, [
buildUiDesignAssetExtractionGenerationInputs(sourceLayer, [
{
id: 'ref-1',
label: '天空',
src: '/sky.png',
resourceId: 'resource-sky',
id: 'ref-ui',
label: 'UI风格参考',
src: '/ui-style.png',
resourceId: 'resource-ui-style',
},
]),
).toEqual({
fields: [
{
title: '提取提示词',
value:
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板或绿色阴影。',
},
],
references: [
{
title: 'UI设计图',
label: '原图',
refType: 'project-resource',
refId: 'resource-source',
},
{
title: '参考图 1',
label: 'UI风格参考',
refType: 'project-resource',
refId: 'resource-ui-style',
},
],
});
expect(
buildQuickEditGenerationInputs(
'快速编辑提示词',
'让图2下雨',
sourceLayer,
[
{
id: 'ref-1',
label: '天空',
src: '/sky.png',
resourceId: 'resource-sky',
},
],
),
).toEqual({
fields: [{ title: '快速编辑提示词', value: '让图2下雨' }],
references: [
@@ -309,8 +369,9 @@ describe('ImageCanvasGenerationModel', () => {
expect(prompt).toContain('生成一张完整游戏UI规范汇总设定展板');
expect(prompt).toContain('玩法设定:消除玩法');
expect(buildSpecPrompt('custom', { ...blankSpecValues, customPrompt: '自定义' }))
.toBe('自定义');
expect(
buildSpecPrompt('custom', { ...blankSpecValues, customPrompt: '自定义' }),
).toBe('自定义');
expect(buildQuickEditModelOptions('nano-banana')).toEqual([
{ label: 'nanobanana2', value: DEFAULT_IMAGE_MODEL },
{ label: 'gpt-image-2', value: 'gpt-image-2' },
@@ -80,7 +80,9 @@ export const EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG = {
},
} as const;
export const SPEC_GENERATION_COST =
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[SPEC_GENERATION_MODEL][SPEC_GENERATION_IMAGE_SIZE];
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[SPEC_GENERATION_MODEL][
SPEC_GENERATION_IMAGE_SIZE
];
export const ICON_DESCRIPTION_LIMIT = 100;
export const DEFAULT_PUBLICATION_GAME_INFO: PublicationMaterialsGameInfo = {
gameName: '',
@@ -194,15 +196,18 @@ export function inferEditorImageAspectRatio(width: number, height: number) {
const safeWidth = Number.isFinite(width) && width > 0 ? Math.round(width) : 1;
const safeHeight =
Number.isFinite(height) && height > 0 ? Math.round(height) : 1;
const commonRatios = EDITOR_IMAGE_DIMENSION_OPTIONS[
IMAGE_MODEL_NANOBANANA2
].aspectRatios as readonly string[];
const commonRatios = EDITOR_IMAGE_DIMENSION_OPTIONS[IMAGE_MODEL_NANOBANANA2]
.aspectRatios as readonly string[];
const sourceRatio = safeWidth / safeHeight;
return commonRatios.reduce((bestRatio, ratio) => {
const bestNumbers = resolveAspectRatioNumbers(bestRatio);
const ratioNumbers = resolveAspectRatioNumbers(ratio);
const bestDiff = Math.abs(bestNumbers.width / bestNumbers.height - sourceRatio);
const ratioDiff = Math.abs(ratioNumbers.width / ratioNumbers.height - sourceRatio);
const bestDiff = Math.abs(
bestNumbers.width / bestNumbers.height - sourceRatio,
);
const ratioDiff = Math.abs(
ratioNumbers.width / ratioNumbers.height - sourceRatio,
);
return ratioDiff < bestDiff ? ratio : bestRatio;
}, commonRatios[0] ?? '1:1');
}
@@ -245,9 +250,15 @@ export function resizeGenerationPlaceholderToImageSelection(
};
}
function resolveEvenVideoWidth(height: number, aspectRatio: string | null | undefined) {
function resolveEvenVideoWidth(
height: number,
aspectRatio: string | null | undefined,
) {
const ratio = resolveAspectRatioNumbers(aspectRatio ?? '16:9');
const rawWidth = Math.max(1, Math.round((height * ratio.width) / ratio.height));
const rawWidth = Math.max(
1,
Math.round((height * ratio.width) / ratio.height),
);
return rawWidth % 2 === 0 ? rawWidth : rawWidth + 1;
}
@@ -382,9 +393,10 @@ export const EDITOR_MODEL_MUD_POINT_CONFIG = {
},
[BACKGROUND_MUSIC_MODEL_SUNO]: {
unit: 'perGeneration',
price: EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG[
BACKGROUND_MUSIC_MODEL_SUNO
],
price:
EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG[
BACKGROUND_MUSIC_MODEL_SUNO
],
},
} satisfies EditorGenerationPricingConfig['models'];
let runtimeEditorGenerationPricingConfig: EditorGenerationPricingConfig = {
@@ -446,7 +458,11 @@ export const EDITOR_VIDEO_ASPECT_RATIO_OPTIONS = [
'3:4',
'21:9',
] as const;
export const EDITOR_VIDEO_RESOLUTION_OPTIONS = ['480p', '720p', '1080p'] as const;
export const EDITOR_VIDEO_RESOLUTION_OPTIONS = [
'480p',
'720p',
'1080p',
] as const;
export const DEFAULT_VIDEO_ASPECT_RATIO = '16:9';
export const DEFAULT_VIDEO_DURATION_SECONDS = 4;
export const DEFAULT_VIDEO_SOUND = 'on';
@@ -509,9 +525,7 @@ export function buildQuickEditSizeOptions(currentSize: string) {
return Array.from(new Set([currentSize, ...QUICK_EDIT_SIZE_PRESETS]));
}
export function normalizeEditorImageModel(
model: string | null | undefined,
) {
export function normalizeEditorImageModel(model: string | null | undefined) {
const normalizedModel = model?.trim();
if (!normalizedModel) {
return DEFAULT_IMAGE_MODEL;
@@ -527,8 +541,9 @@ export function getEditorImageModelDisplayName(
) {
const normalizedModel = normalizeEditorImageModel(model);
return (
EDITOR_IMAGE_MODEL_OPTIONS.find((option) => option.value === normalizedModel)
?.label ?? normalizedModel
EDITOR_IMAGE_MODEL_OPTIONS.find(
(option) => option.value === normalizedModel,
)?.label ?? normalizedModel
);
}
@@ -537,7 +552,10 @@ export function buildQuickEditModelOptions(currentModel: string) {
const options = [...QUICK_EDIT_MODEL_OPTIONS];
return options.some((option) => option.value === normalizedCurrentModel)
? options
: [{ label: normalizedCurrentModel, value: normalizedCurrentModel }, ...options];
: [
{ label: normalizedCurrentModel, value: normalizedCurrentModel },
...options,
];
}
export function buildCharacterSpecPrompt(values: SpecFormValues) {
@@ -734,9 +752,7 @@ function readModelTierPrice({
function readModelFlatPrice(model: string, fallbackModel: string) {
return (
getModelPrices(model)?.price ??
getModelPrices(fallbackModel)?.price ??
0
getModelPrices(model)?.price ?? getModelPrices(fallbackModel)?.price ?? 0
);
}
@@ -789,7 +805,10 @@ export function calculateEditorImageModelPrice(
export function calculateEditorSpecGenerationPrice(
model: string | null | undefined = SPEC_GENERATION_MODEL,
) {
return calculateEditorImageModelPrice(model ?? SPEC_GENERATION_MODEL, SPEC_GENERATION_IMAGE_SIZE);
return calculateEditorImageModelPrice(
model ?? SPEC_GENERATION_MODEL,
SPEC_GENERATION_IMAGE_SIZE,
);
}
export function calculateEditorImageGenerationPrice({
@@ -870,9 +889,10 @@ export function resolveCharacterAnimationSourceImageSrc(layer: CanvasLayer) {
export function createCanvasLayerReference(
layer: CanvasLayer,
): CharacterReferenceImage {
const mediaType = layer.mediaType === 'video' || layer.mediaType === 'audio'
? layer.mediaType
: undefined;
const mediaType =
layer.mediaType === 'video' || layer.mediaType === 'audio'
? layer.mediaType
: undefined;
return {
id: `canvas-${layer.id}`,
label: layer.title,
@@ -938,10 +958,10 @@ export function appendLimitedQuickEditReferences(
const imageReferences = nextReferences.filter(
(reference) => (reference.mediaType ?? 'image') === 'image',
);
return [
...(references ?? []),
...imageReferences,
].slice(0, QUICK_EDIT_REFERENCE_LIMIT);
return [...(references ?? []), ...imageReferences].slice(
0,
QUICK_EDIT_REFERENCE_LIMIT,
);
}
export function createGenerationInputField(
@@ -1029,7 +1049,10 @@ export function buildSpecGenerationInputs(
: [];
if (specType === 'custom') {
return {
fields: createGenerationInputField('自定义规范提示词', values.customPrompt),
fields: createGenerationInputField(
'自定义规范提示词',
values.customPrompt,
),
references,
};
}
@@ -1071,12 +1094,18 @@ export function buildCharacterGenerationInputs(
export function buildUiDesignGenerationInputs(
prompt: string,
specReference: CharacterReferenceImage | null | undefined,
references?: CharacterReferenceImage[],
): CanvasGenerationInputs {
return {
fields: createGenerationInputField('用户输入', prompt),
references: specReference
? createGenerationInputReference('图标规范', specReference)
: [],
references: [
...(specReference
? createGenerationInputReference('图标规范', specReference)
: []),
...(references ?? []).flatMap((reference, index) =>
createGenerationInputReference(`参考图 ${index + 1}`, reference),
),
],
};
}
@@ -1096,7 +1125,10 @@ export function buildPublicationMaterialsPrompt(
const normalizedGameInfo = normalizePublicationGameInfo(gameInfo);
return [
...createGenerationInputField('游戏名', normalizedGameInfo.gameName),
...createGenerationInputField('游戏分类', normalizedGameInfo.gameCategories),
...createGenerationInputField(
'游戏分类',
normalizedGameInfo.gameCategories,
),
...createGenerationInputField(
'一句话描述游戏',
normalizedGameInfo.gameDescription,
@@ -1116,7 +1148,9 @@ export function buildPublicationMaterialsGenerationPrompt({
const inputPrompt = buildPublicationMaterialsPrompt(gameInfo);
return [
`【宣发素材类型】${workflow.promptLabel ?? workflow.label}`,
inputPrompt ? `【游戏输入】\n${inputPrompt}` : '【游戏输入】\n请根据参考图生成宣发素材。',
inputPrompt
? `【游戏输入】\n${inputPrompt}`
: '【游戏输入】\n请根据参考图生成宣发素材。',
`【生图约束】\n${workflow.promptConstraints.join('\n')}`,
`【参考图约束】\n${workflow.referenceConstraints.join('\n')}`,
`【输出检查】\n${workflow.postProcessConstraints.join('\n')}`,
@@ -1131,7 +1165,10 @@ export function buildPublicationMaterialsGenerationInputs(
return {
fields: [
...createGenerationInputField('游戏名', normalizedGameInfo.gameName),
...createGenerationInputField('游戏分类', normalizedGameInfo.gameCategories),
...createGenerationInputField(
'游戏分类',
normalizedGameInfo.gameCategories,
),
...createGenerationInputField(
'一句话描述游戏',
normalizedGameInfo.gameDescription,
@@ -1146,6 +1183,7 @@ export function buildPublicationMaterialsGenerationInputs(
export function buildIconGenerationInputs(
iconDescriptions: string[],
specReference: CharacterReferenceImage,
references?: CharacterReferenceImage[],
): CanvasGenerationInputs {
return {
fields: [
@@ -1154,19 +1192,30 @@ export function buildIconGenerationInputs(
value: iconDescriptions.join('\n'),
},
],
references: createGenerationInputReference('图标规范', specReference),
references: [
...createGenerationInputReference('图标规范', specReference),
...(references ?? []).flatMap((reference, index) =>
createGenerationInputReference(`参考图 ${index + 1}`, reference),
),
],
};
}
export function buildUiDesignAssetExtractionGenerationInputs(
sourceLayer: CanvasLayer,
references?: CharacterReferenceImage[],
): CanvasGenerationInputs {
return {
fields: createGenerationInputField(
'提取提示词',
UI_DESIGN_ASSET_EXTRACTION_PROMPT,
),
references: createLayerGenerationInputReference('UI设计图', sourceLayer),
references: [
...createLayerGenerationInputReference('UI设计图', sourceLayer),
...(references ?? []).flatMap((reference, index) =>
createGenerationInputReference(`参考图 ${index + 1}`, reference),
),
],
};
}
@@ -1267,9 +1316,11 @@ export function getGenerationFrameLabel(dialog: CanvasGenerationDialogState) {
return 'Icon Generator';
}
if (dialog.mode === 'publication') {
return `${getPublicationMaterialsWorkflow(
dialog.publicationWorkflowId ?? 'publication-cover-image',
).englishName} Generator`;
return `${
getPublicationMaterialsWorkflow(
dialog.publicationWorkflowId ?? 'publication-cover-image',
).englishName
} Generator`;
}
if (dialog.mode === 'ui-design') {
return 'UI Design Generator';
@@ -343,6 +343,14 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
src: 'data:image/png;base64,ui-spec',
resourceId: 'resource-ui-spec',
},
generationReferences: [
{
id: 'ui-ref',
label: '界面风格参考',
src: 'data:image/png;base64,ui-ref',
resourceId: 'resource-ui-ref',
},
],
},
layers: [],
nextGeneratedIndex: 6,
@@ -356,11 +364,31 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
model: IMAGE_MODEL_GPT_IMAGE_2,
aspectRatio: '16:9',
imageSize: '1K',
referenceImageSrcs: ['data:image/png;base64,ui-spec'],
referenceImageSrcs: [
'data:image/png;base64,ui-spec',
'data:image/png;base64,ui-ref',
],
},
result: {
assetKind: 'ui-design',
title: 'UI设计图 6',
generationInputs: {
fields: [{ title: '用户输入', value: '设计移动端商城首页' }],
references: [
{
title: '图标规范',
label: 'UI图标规范',
refType: 'project-resource',
refId: 'resource-ui-spec',
},
{
title: '参考图 1',
label: '界面风格参考',
refType: 'project-resource',
refId: 'resource-ui-ref',
},
],
},
},
rememberImageModel: IMAGE_MODEL_GPT_IMAGE_2,
});
@@ -611,6 +639,14 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
src: 'data:image/png;base64,spec',
resourceId: 'resource-icon-spec',
},
generationReferences: [
{
id: 'icon-ref',
label: '按钮风格参考',
src: 'data:image/png;base64,ref',
resourceId: 'resource-icon-ref',
},
],
iconDescriptions: ['旧返回', '旧设置'],
});
@@ -619,6 +655,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
iconDescriptions: ['返回按钮', '设置按钮'],
input: {
referenceImageSrc: 'data:image/png;base64,spec',
referenceImageSrcs: ['data:image/png;base64,ref'],
iconDescriptions: ['返回按钮', '设置按钮'],
model: 'gpt-image-2',
aspectRatio: '3:2',
@@ -633,6 +670,12 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
refType: 'project-resource',
refId: 'resource-icon-spec',
},
{
title: '参考图 1',
label: '按钮风格参考',
refType: 'project-resource',
refId: 'resource-icon-ref',
},
],
},
rememberImageModel: 'gpt-image-2',
@@ -72,7 +72,7 @@ function normalizeGptImageSize(imageSize: string | null | undefined) {
return imageSize?.trim().toUpperCase() === '2K' ? '2K' : '1K';
}
function resolveImageReferenceSubmissionSource(reference: {
export function resolveImageReferenceSubmissionSource(reference: {
src: string;
objectKey?: string | null;
}) {
@@ -378,6 +378,9 @@ export function buildImageGenerationSubmissionPlan({
dialog.uiDesignSpecReference
? resolveImageReferenceSubmissionSource(dialog.uiDesignSpecReference)
: null,
...(dialog.generationReferences ?? []).map((reference) =>
resolveImageReferenceSubmissionSource(reference),
),
].filter((src): src is string => Boolean(src));
return {
kind: 'image',
@@ -396,6 +399,7 @@ export function buildImageGenerationSubmissionPlan({
generationInputs: buildUiDesignGenerationInputs(
normalizedPrompt,
dialog.uiDesignSpecReference,
dialog.generationReferences,
),
},
rememberImageModel: imageModel,
@@ -546,8 +550,8 @@ export function buildImageGenerationSubmissionPlan({
imageSize: dialog.imageSize ?? '1K',
...(dialog.generationReferences?.length
? {
referenceImageSrcs: dialog.generationReferences.map(
(reference) => resolveImageReferenceSubmissionSource(reference),
referenceImageSrcs: dialog.generationReferences.map((reference) =>
resolveImageReferenceSubmissionSource(reference),
),
}
: {}),
@@ -597,6 +601,13 @@ export function buildIconSpritesheetGenerationSubmissionPlan(
referenceImageSrc: resolveImageReferenceSubmissionSource(
dialog.iconSpecReference,
),
...(dialog.generationReferences?.length
? {
referenceImageSrcs: dialog.generationReferences.map((reference) =>
resolveImageReferenceSubmissionSource(reference),
),
}
: {}),
iconDescriptions,
model: rememberImageModel,
aspectRatio: dialog.aspectRatio ?? '1:1',
@@ -605,6 +616,7 @@ export function buildIconSpritesheetGenerationSubmissionPlan(
generationInputs: buildIconGenerationInputs(
iconDescriptions,
dialog.iconSpecReference,
dialog.generationReferences,
),
rememberImageModel,
};
@@ -119,7 +119,9 @@ describe('ImageCanvasIconSpritesheetComposerView', () => {
render(<IconComposerHarness initialDialog={createIconDialog()} />);
const panel = screen.getByRole('dialog', { name: '生成图标素材' });
const firstRow = panel.querySelector('.image-canvas-editor__reference-strip');
const firstRow = panel.querySelector(
'.image-canvas-editor__reference-strip',
);
expect(firstRow).toBeTruthy();
expect(
@@ -154,6 +156,31 @@ describe('ImageCanvasIconSpritesheetComposerView', () => {
expect(screen.queryByRole('button', { name: '图标规范A' })).toBeNull();
});
it('uploads and removes icon generation references', () => {
const requestUpload = vi.fn();
render(
<IconComposerHarness
initialDialog={createIconDialog({
generationReferences: [
{
id: 'ref-a',
label: '按钮风格参考',
src: 'data:image/png;base64,ref',
},
],
})}
onRequestUpload={requestUpload}
/>,
);
expect(screen.getByLabelText('按钮风格参考')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '上传图标素材参考图' }));
expect(requestUpload).toHaveBeenCalledWith('generation-reference');
fireEvent.click(screen.getByRole('button', { name: '删除按钮风格参考' }));
expect(screen.queryByLabelText('按钮风格参考')).toBeNull();
});
it('updates multi-line descriptions and submits', () => {
const updateIconDescriptionText = vi.fn();
const submitIconGeneration = vi.fn();
@@ -214,9 +241,9 @@ describe('ImageCanvasIconSpritesheetComposerView', () => {
fireEvent.click(screen.getByRole('button', { name: '生成' }));
expect((screen.getByLabelText('素材描述') as HTMLInputElement).disabled).toBe(
true,
);
expect(
(screen.getByLabelText('素材描述') as HTMLInputElement).disabled,
).toBe(true);
expect(submitIconGeneration).not.toHaveBeenCalled();
rerender(
@@ -1,4 +1,4 @@
import { ImageIcon } from 'lucide-react';
import { ImageIcon, ImagePlus } from 'lucide-react';
import {
type CSSProperties,
type Dispatch,
@@ -84,6 +84,7 @@ export function ImageCanvasIconSpritesheetComposerView({
setIsIconSpecMenuOpen(false);
onRequestUpload('icon-spec');
};
const references = dialog.generationReferences ?? [];
return (
<form
@@ -142,10 +143,7 @@ export function ImageCanvasIconSpritesheetComposerView({
className="image-canvas-editor__character-spec-menu image-canvas-editor__portal-menu"
label="图标规范来源"
placement="top-start"
style={buildPortalMenuStyle(
iconSpecButtonRef.current,
'above',
)}
style={buildPortalMenuStyle(iconSpecButtonRef.current, 'above')}
>
<PlatformFloatingMenuItem
className="image-canvas-editor__context-menu-item"
@@ -168,6 +166,49 @@ export function ImageCanvasIconSpritesheetComposerView({
</PlatformFloatingMenu>,
)
: null}
{references.map((reference, index) => (
<ImageCanvasReferenceSlot
key={reference.id}
tone="default"
icon={<ImageIcon className="h-4 w-4" aria-hidden="true" />}
imageSrc={reference.src}
objectKey={reference.objectKey}
label={`参考图${index + 1}`}
ariaLabel={reference.label || `参考图${index + 1}`}
title={reference.label}
disabled={dialog.status === 'generating'}
onRemove={() =>
setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'icon'
? {
...currentDialog,
status:
currentDialog.status === 'failed'
? 'idle'
: currentDialog.status,
errorMessage:
currentDialog.status === 'failed'
? undefined
: currentDialog.errorMessage,
generationReferences: (
currentDialog.generationReferences ?? []
).filter((item) => item.id !== reference.id),
}
: currentDialog,
)
}
removeLabel={`删除${reference.label || `参考图${index + 1}`}`}
/>
))}
<ImageCanvasReferenceSlot
tone="default"
icon={<ImagePlus className="h-4 w-4" aria-hidden="true" />}
label="参考图"
ariaLabel="上传图标素材参考图"
disabled={dialog.status === 'generating'}
isAdd
onClick={() => onRequestUpload('generation-reference')}
/>
</div>
<label className="image-canvas-editor__field-block image-canvas-editor__field-block--single">
<PlatformTextField
@@ -110,13 +110,17 @@ describe('ImageCanvasQuickEditPanelView', () => {
);
expect(screen.getByLabelText('当前状态').textContent).toBe('idle');
expect(screen.getByLabelText('当前错误').textContent).toBe('-');
fireEvent.click(screen.getByRole('button', { name: '快速编辑尺寸 1:1·1K' }));
fireEvent.click(
screen.getByRole('button', { name: '快速编辑尺寸 1:1·1K' }),
);
fireEvent.click(screen.getByRole('button', { name: '比例 16:9' }));
fireEvent.click(screen.getByRole('button', { name: '尺寸 2K' }));
expect(screen.getByLabelText('当前比例').textContent).toBe('16:9');
expect(screen.getByLabelText('当前图片规格').textContent).toBe('2K');
fireEvent.click(screen.getByRole('button', { name: '快速编辑模型 nanobanana2' }));
fireEvent.click(
screen.getByRole('button', { name: '快速编辑模型 nanobanana2' }),
);
fireEvent.click(screen.getByRole('button', { name: 'gpt-image-2' }));
expect(screen.getByLabelText('当前模型').textContent).toBe('gpt-image-2');
expect(rememberImageModel).toHaveBeenCalledWith('gpt-image-2');
@@ -247,9 +251,11 @@ describe('ImageCanvasQuickEditPanelView', () => {
);
expect(
(screen.getByRole('button', {
name: '添加快速编辑参考图',
}) as HTMLButtonElement).disabled,
(
screen.getByRole('button', {
name: '添加快速编辑参考图',
}) as HTMLButtonElement
).disabled,
).toBe(true);
expect(screen.queryByLabelText('原图 图9')).toBeNull();
});
@@ -49,7 +49,7 @@ function createQuickEditDialog(
panel: QuickEditPanelState,
): GenerateDialogState {
return {
mode: 'quick-edit',
mode: panel.mode === 'redraw' ? 'generate' : 'quick-edit',
prompt: panel.prompt,
status: panel.status,
sourceLayerId: panel.sourceLayerId,
@@ -54,8 +54,10 @@ function renderPanel({
function UiDesignHarness({
initialDialog,
onRequestUpload = vi.fn(),
}: {
initialDialog: GenerateDialogState;
onRequestUpload?: (target: UploadTarget) => void;
}) {
const [dialog, setDialog] = useState<GenerateDialogState | null>(
initialDialog,
@@ -71,7 +73,7 @@ function UiDesignHarness({
renderEditorPortal={(node) => node}
buildPortalMenuStyle={() => ({ position: 'fixed', left: 0, top: 0 })}
onUpdateSpecFormValue={vi.fn()}
onRequestUpload={vi.fn()}
onRequestUpload={onRequestUpload}
onSubmit={vi.fn()}
/>
) : null;
@@ -154,6 +156,39 @@ describe('ImageCanvasSpecGenerationPanelView', () => {
expect(screen.queryByText('nanobanana2')).toBeNull();
});
it('uploads and removes UI design generation references', () => {
const requestUpload = vi.fn();
render(
<UiDesignHarness
onRequestUpload={requestUpload}
initialDialog={{
mode: 'ui-design',
prompt: '',
status: 'idle',
composerOpen: true,
uiDesignSpecReference: null,
generationReferences: [
{
id: 'ref-a',
label: '界面风格参考',
src: 'data:image/png;base64,ref',
},
],
imageModel: 'gpt-image-2',
aspectRatio: '16:9',
imageSize: '1K',
}}
/>,
);
expect(screen.getByLabelText('界面风格参考')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '上传UI设计参考图' }));
expect(requestUpload).toHaveBeenCalledWith('generation-reference');
fireEvent.click(screen.getByRole('button', { name: '删除界面风格参考' }));
expect(screen.queryByLabelText('界面风格参考')).toBeNull();
});
it('renders character spec fields and forwards updates', () => {
const updateSpecFormValue = vi.fn();
renderPanel({
@@ -192,6 +192,59 @@ export function ImageCanvasSpecGenerationPanelView({
removeLabel={`删除${referenceLabel}`}
/>
) : null}
{isUiDesignDialog
? (dialog.generationReferences ?? []).map((item, index) => (
<ImageCanvasReferenceSlot
key={item.id}
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) =>
currentDialog?.mode === 'ui-design'
? {
...currentDialog,
status:
currentDialog.status === 'failed'
? 'idle'
: currentDialog.status,
errorMessage:
currentDialog.status === 'failed'
? undefined
: currentDialog.errorMessage,
generationReferences: (
currentDialog.generationReferences ?? []
).filter(
(referenceItem) =>
referenceItem.id !== item.id,
),
}
: currentDialog,
)
: undefined
}
removeLabel={`删除${item.label || `参考图${index + 1}`}`}
/>
))
: null}
{isUiDesignDialog ? (
<ImageCanvasReferenceSlot
tone="default"
icon={<ImagePlus className="h-4 w-4" aria-hidden="true" />}
label="参考图"
ariaLabel="上传UI设计参考图"
disabled={isGenerating}
isAdd
onClick={() => onRequestUpload('generation-reference')}
/>
) : null}
</div>
<div className="image-canvas-editor__spec-fields">
{isUiDesignDialog ? (
@@ -123,6 +123,8 @@ export type ImageCanvasStageViewProps = {
onUiAssetExtractionPointerStart: (point: { x: number; y: number }) => void;
onUiAssetExtractionPointerMove: (point: { x: number; y: number }) => void;
onUiAssetExtractionPointerEnd: () => void;
onRequestUiAssetExtractionReferenceUpload: () => void;
onRemoveUiAssetExtractionReference: (referenceId: string) => void;
onSubmitUiAssetExtraction: () => void;
onQuickEditSelectionToolChange: (tool: UiAssetExtractionTool | null) => void;
onQuickEditSelectionPointerStart: (point: { x: number; y: number }) => void;
@@ -237,6 +239,8 @@ export function ImageCanvasStageView({
onUiAssetExtractionPointerStart,
onUiAssetExtractionPointerMove,
onUiAssetExtractionPointerEnd,
onRequestUiAssetExtractionReferenceUpload,
onRemoveUiAssetExtractionReference,
onSubmitUiAssetExtraction,
onQuickEditSelectionToolChange,
onQuickEditSelectionPointerStart,
@@ -348,6 +352,8 @@ export function ImageCanvasStageView({
onPointerMove={onUiAssetExtractionPointerMove}
onPointerEnd={onUiAssetExtractionPointerEnd}
onModelChange={onUiAssetExtractionModelChange}
onRequestUpload={onRequestUiAssetExtractionReferenceUpload}
onRemoveReference={onRemoveUiAssetExtractionReference}
onSubmit={onSubmitUiAssetExtraction}
/>
@@ -1,3 +1,5 @@
import type { CharacterReferenceImage } from './ImageCanvasEditorTypes';
export type UiAssetExtractionTool = 'rect' | 'ellipse' | 'brush';
export type UiAssetExtractionPoint = {
@@ -29,6 +31,7 @@ export type UiAssetExtractionState = {
tool: UiAssetExtractionTool | null;
model: string;
marks: UiAssetExtractionMark[];
references: CharacterReferenceImage[];
draftMark: UiAssetExtractionMark | null;
status: 'idle' | 'extracting' | 'failed';
errorMessage?: string;
@@ -52,6 +55,7 @@ export function createUiAssetExtractionState(
tool: options.initialTool === undefined ? 'rect' : options.initialTool,
model: options.model,
marks: [],
references: [],
draftMark: null,
status: 'idle',
};
@@ -66,6 +66,7 @@ function createState(
tool: 'rect',
model: IMAGE_MODEL_NANOBANANA2,
marks: [],
references: [],
draftMark: null,
status: 'idle',
...overrides,
@@ -129,15 +130,29 @@ describe('ImageCanvasUiAssetExtractionOverlayView', () => {
height: 36,
}));
const onRequestUpload = vi.fn();
const onRemoveReference = vi.fn();
render(
<ImageCanvasUiAssetExtractionOverlayView
sourceLayer={createLayer({ src: 'data:image/png;base64,ui' })}
viewport={{ x: 10, y: 20, scale: 1 }}
state={createState({ marks })}
state={createState({
marks,
references: [
{
id: 'ui-ref-1',
label: '参考UI.png',
src: 'data:image/png;base64,ui-ref',
},
],
})}
onToolChange={vi.fn()}
onPointerStart={vi.fn()}
onPointerMove={vi.fn()}
onPointerEnd={vi.fn()}
onRequestUpload={onRequestUpload}
onRemoveReference={onRemoveReference}
onSubmit={vi.fn()}
/>,
);
@@ -157,6 +172,15 @@ describe('ImageCanvasUiAssetExtractionOverlayView', () => {
expect(
within(panel).getByRole('button', { name: '提取素材模型 nanobanana2' }),
).toBeTruthy();
expect(within(panel).getByLabelText('参考UI.png')).toBeTruthy();
fireEvent.click(
within(panel).getByRole('button', { name: '上传UI素材参考图' }),
);
fireEvent.click(
within(panel).getByRole('button', { name: '删除参考UI.png' }),
);
expect(onRequestUpload).toHaveBeenCalledTimes(1);
expect(onRemoveReference).toHaveBeenCalledWith('ui-ref-1');
expect(
within(panel).queryByRole('button', { name: '取消框选' }),
).toBeNull();
@@ -3,6 +3,8 @@ import {
ChevronDown,
Circle,
Cpu,
ImageIcon,
ImagePlus,
Paintbrush,
Square,
} from 'lucide-react';
@@ -21,6 +23,7 @@ import {
EDITOR_IMAGE_MODEL_OPTIONS,
getEditorImageModelDisplayName,
} from './ImageCanvasGenerationModel';
import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
import type {
UiAssetExtractionMark,
UiAssetExtractionState,
@@ -42,6 +45,8 @@ type ImageCanvasUiAssetExtractionOverlayViewProps = {
onPointerMove: (point: { x: number; y: number }) => void;
onPointerEnd: () => void;
onModelChange?: (model: string) => void;
onRequestUpload?: () => void;
onRemoveReference?: (referenceId: string) => void;
onSubmit?: () => void;
};
@@ -267,6 +272,8 @@ export function ImageCanvasUiAssetExtractionOverlayView({
onPointerMove,
onPointerEnd,
onModelChange = () => {},
onRequestUpload,
onRemoveReference,
onSubmit,
}: ImageCanvasUiAssetExtractionOverlayViewProps) {
const [isModelMenuOpen, setIsModelMenuOpen] = useState(false);
@@ -391,6 +398,38 @@ export function ImageCanvasUiAssetExtractionOverlayView({
<div className="image-canvas-editor__ui-extraction-panel-head">
使用框选工具框选你希望从画面中提取的素材
</div>
<div className="image-canvas-editor__reference-strip">
{state.references.map((reference, index) => (
<ImageCanvasReferenceSlot
key={reference.id}
tone="default"
icon={<ImageIcon className="h-4 w-4" aria-hidden="true" />}
imageSrc={reference.src}
objectKey={reference.objectKey}
label={`参考图${index + 1}`}
ariaLabel={reference.label || `参考图${index + 1}`}
title={reference.label}
disabled={state.status === 'extracting'}
onRemove={
onRemoveReference
? () => onRemoveReference(reference.id)
: undefined
}
removeLabel={`删除${reference.label || `参考图${index + 1}`}`}
/>
))}
{onRequestUpload ? (
<ImageCanvasReferenceSlot
tone="default"
icon={<ImagePlus className="h-4 w-4" aria-hidden="true" />}
label="参考图"
ariaLabel="上传UI素材参考图"
disabled={state.status === 'extracting'}
isAdd
onClick={onRequestUpload}
/>
) : null}
</div>
<div
className="image-canvas-editor__ui-extraction-preview-list"
aria-label="预览列表"
@@ -109,7 +109,11 @@ describe('ImageCanvasUploadModel', () => {
resolveUploadFolderId({
assetFolders: [
...folders,
createFolder({ id: 'uploaded', label: '上传素材', systemDefault: false }),
createFolder({
id: 'uploaded',
label: '上传素材',
systemDefault: false,
}),
],
activeUploadFolderId: 'characters',
preferUploadFolder: true,
@@ -157,7 +161,11 @@ describe('ImageCanvasUploadModel', () => {
expandUploadFolder({
folders: [
createFolder({ id: 'project', collapsed: true }),
createFolder({ id: 'characters', label: '角色素材', collapsed: true }),
createFolder({
id: 'characters',
label: '角色素材',
collapsed: true,
}),
],
folderId: 'characters',
}),
@@ -429,6 +437,37 @@ describe('ImageCanvasUploadModel', () => {
).toBe(iconDialog);
});
it('appends generation reference uploads to icon and UI design dialogs', () => {
const reference = {
id: 'ref-new',
label: '参考图',
src: 'data:image/png;base64,ref',
};
expect(
applyGenerationReferenceUpload({
dialog: createDialog({ mode: 'icon' }),
target: 'generation-reference',
references: [reference],
}),
).toMatchObject({
mode: 'icon',
status: 'idle',
generationReferences: [reference],
});
expect(
applyGenerationReferenceUpload({
dialog: createDialog({ mode: 'ui-design' }),
target: 'generation-reference',
references: [reference],
}),
).toMatchObject({
mode: 'ui-design',
status: 'idle',
generationReferences: [reference],
});
});
it('creates upload canvas layers centered on the target canvas point', () => {
const layer = createUploadCanvasLayer({
uploadIndex: 1,
@@ -459,7 +498,8 @@ describe('ImageCanvasUploadModel', () => {
uploadIndex: 1,
fileName: '画布素材.png',
imageSrc: 'data:image/png;base64,canvas',
objectKey: 'generated-character-drafts/editor/asset-library/image/画布素材.png',
objectKey:
'generated-character-drafts/editor/asset-library/image/画布素材.png',
assetObjectId: 'assetobj-image',
imageDimensions: { width: 1536, height: 1024 },
canvasPoint: { x: 110, y: 120 },
@@ -207,7 +207,9 @@ export function applyGenerationReferenceUpload({
if (target === 'generation-reference') {
return dialog?.mode === 'generate' ||
dialog?.mode === 'video' ||
dialog?.mode === 'spec'
dialog?.mode === 'spec' ||
dialog?.mode === 'icon' ||
dialog?.mode === 'ui-design'
? {
...setFailedGenerationIdle(dialog),
generationReferences: [
@@ -317,7 +319,13 @@ export function createUploadingAssetPlaceholder({
: fallbackSize;
return {
id: `upload-${uploadIndex}`,
label: fileName || (mediaType === 'audio' ? '上传音频' : mediaType === 'video' ? '上传视频' : '上传图片'),
label:
fileName ||
(mediaType === 'audio'
? '上传音频'
: mediaType === 'video'
? '上传视频'
: '上传图片'),
src: '',
mediaType,
width: size.width,
@@ -430,7 +438,8 @@ export function applyPersistedUploadAsset({
height: persistedAsset.height,
objectKey: persistedAsset.objectKey ?? undefined,
assetObjectId: persistedAsset.assetObjectId ?? undefined,
durationSeconds: persistedAsset.durationSeconds ?? asset.durationSeconds,
durationSeconds:
persistedAsset.durationSeconds ?? asset.durationSeconds,
persisted: true,
uploadStatus: undefined,
uploadProgress: undefined,
@@ -567,7 +576,13 @@ export function createUploadCanvasLayer({
return {
id: `layer-upload-${uploadIndex}`,
resourceId: `local-resource-upload-${uploadIndex}`,
title: fileName || (mediaType === 'audio' ? '上传音频' : mediaType === 'video' ? '上传视频' : '上传图片'),
title:
fileName ||
(mediaType === 'audio'
? '上传音频'
: mediaType === 'video'
? '上传视频'
: '上传图片'),
src: imageSrc,
mediaType,
assetKind:
@@ -606,8 +621,7 @@ export function resizeUploadCanvasLayerToImage({
canvasSize: CanvasSize;
viewport: CanvasViewport;
}): CanvasLayer {
const safeOriginalWidth =
originalWidth || UPLOAD_LAYER_FALLBACK_SIZE.width;
const safeOriginalWidth = originalWidth || UPLOAD_LAYER_FALLBACK_SIZE.width;
const safeOriginalHeight =
originalHeight || UPLOAD_LAYER_FALLBACK_SIZE.height;
const { width, height } = resolveLayerResolutionSize(
@@ -373,6 +373,36 @@ function SubmissionWorkflowHarness({
>
提取已框选UI素材
</button>
<button
type="button"
onClick={() => {
if (sourceLayer) {
void workflow.extractUiDesignAssets(sourceLayer, {
marks: [
{
id: 'mark-ref',
tool: 'rect',
x: 10,
y: 20,
width: 80,
height: 60,
},
],
references: [
{
id: 'ref-a',
label: '风格参考',
src: '/generated/ref-fallback.png',
objectKey: 'generated/ref-style.png',
resourceId: 'resource-style-ref',
},
],
});
}
}}
>
提取带参考图UI素材
</button>
<button
type="button"
onClick={() => void workflow.submitCharacterAnimation()}
@@ -612,6 +642,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
height: 120,
},
],
references: [],
draftMark: null,
status: 'idle',
}}
@@ -1566,10 +1597,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
);
});
it('resolves uploaded objectKey icon spec before submitting spritesheets', async () => {
resolveEditorImageReferenceDataUrlForGenerationMock.mockResolvedValueOnce(
'data:image/jpeg;base64,normalized-icon-spec',
);
it('submits uploaded objectKey icon references before submitting spritesheets', async () => {
generateEditorIconSpritesheetMock.mockResolvedValueOnce({
spritesheetImageSrc: 'data:image/png;base64,sheet',
spritesheetWidth: 512,
@@ -1597,6 +1625,15 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
objectKey: 'generated-character-drafts/editor/specs/icon-spec.png',
assetObjectId: 'assetobj-icon-spec',
},
generationReferences: [
{
id: 'ref-style',
label: '风格参考',
src: 'https://signed.example.test/icon-style.png?expires=1',
objectKey: 'generated-character-drafts/editor/refs/icon-style.png',
assetObjectId: 'assetobj-icon-style',
},
],
}}
/>,
);
@@ -1605,14 +1642,14 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
fireEvent.click(screen.getByRole('button', { name: '提交图标' }));
await waitFor(() => {
expect(
resolveEditorImageReferenceDataUrlForGenerationMock,
).toHaveBeenCalledWith(
'generated-character-drafts/editor/specs/icon-spec.png',
);
expect(resolveEditorImageReferenceDataUrlForGenerationMock).not.toHaveBeenCalled();
expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith(
expect.objectContaining({
referenceImageSrc: 'data:image/jpeg;base64,normalized-icon-spec',
referenceImageSrc:
'generated-character-drafts/editor/specs/icon-spec.png',
referenceImageSrcs: [
'generated-character-drafts/editor/refs/icon-style.png',
],
iconDescriptions: ['返回按钮'],
}),
);
@@ -1648,19 +1685,17 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
fireEvent.click(screen.getByRole('button', { name: '提取UI素材' }));
await waitFor(() => {
expect(resolveEditorImageReferenceDataUrlMock).toHaveBeenCalledWith(
'generated/ui-design.png',
);
});
await waitFor(() => {
expect(extractEditorUiDesignAssetsMock).toHaveBeenCalledWith(
expect.objectContaining({
sourceImageSrc: 'data:image/png;base64,ui-design.png',
sourceImageSrc: 'generated/ui-design.png',
model: 'gemini-3.1-flash-image-preview',
}),
);
});
expect(resolveEditorImageReferenceDataUrlMock).not.toHaveBeenCalledWith(
'generated/ui-design.png',
);
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
'layer-icon-spritesheet-1:源图 素材图集:-:icon-spritesheet',
@@ -1785,6 +1820,53 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
});
});
it('submits UI asset extraction with uploaded references', async () => {
extractEditorUiDesignAssetsMock.mockResolvedValueOnce({
spritesheetImageSrc: 'data:image/png;base64,ui-sheet',
spritesheetWidth: 512,
spritesheetHeight: 512,
prompt:
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板或绿色阴影。',
actualPrompt:
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板或绿色阴影。',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'task-ui-assets',
});
render(
<SubmissionWorkflowHarness
initialLayers={[
createLayer({
assetKind: 'ui-design',
objectKey: 'generated/ui-design.png',
}),
]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '提取带参考图UI素材' }));
await waitFor(() => {
expect(resolveEditorImageReferenceDataUrlForGenerationMock).not.toHaveBeenCalled();
});
await waitFor(() => {
expect(extractEditorUiDesignAssetsMock).toHaveBeenCalledWith(
expect.objectContaining({
referenceImageSrcs: ['generated/ref-style.png'],
generationInputs: expect.objectContaining({
references: expect.arrayContaining([
expect.objectContaining({
title: '参考图 1',
label: '风格参考',
refType: 'project-resource',
}),
]),
}),
}),
);
});
});
it('keeps character generation dialog data after the result is created', async () => {
generateEditorImageMock.mockResolvedValueOnce(
createGenerated({ width: 768, height: 768 }),
@@ -34,6 +34,7 @@ import type {
CanvasLayer,
CanvasTool,
CanvasViewport,
CharacterReferenceImage,
CharacterAnimationPanelState,
GenerateDialogState,
QuickEditPanelState,
@@ -73,6 +74,7 @@ import {
buildCharacterAnimationSubmissionPlan,
buildIconSpritesheetGenerationSubmissionPlan,
buildImageGenerationSubmissionPlan,
resolveImageReferenceSubmissionSource,
} from './ImageCanvasGenerationSubmissionModel';
import type {
UiAssetExtractionMark,
@@ -90,6 +92,7 @@ type CanvasGenerationDialogUpdater = (
type UiDesignAssetExtractionOptions = {
marks?: UiAssetExtractionMark[];
model?: string;
references?: CharacterReferenceImage[];
suppressAlert?: boolean;
};
@@ -159,17 +162,6 @@ async function normalizeImageGenerationReferenceImages(
};
}
async function normalizeIconSpritesheetReferenceImage(
input: Parameters<typeof generateEditorIconSpritesheet>[0],
) {
return {
...input,
referenceImageSrc: await resolveEditorImageReferenceDataUrlForGeneration(
input.referenceImageSrc,
),
};
}
function delay(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
@@ -214,9 +206,7 @@ async function waitForEditorGenerationQueue(
return null;
}
await delay(EDITOR_GENERATION_QUEUE_POLL_INTERVAL_MS);
current = (
await getExternalGenerationJobStatus(current.operationId)
).job;
current = (await getExternalGenerationJobStatus(current.operationId)).job;
}
if (current.status === 'failed') {
throw new Error(current.error?.trim() || '生成任务失败');
@@ -250,7 +240,8 @@ async function applyQueuedEditorGenerationProject(
}
function positiveCanvasSize(value: number | null | undefined, fallback = 1) {
const resolved = Number.isFinite(value) && (value ?? 0) > 0 ? value : fallback;
const resolved =
Number.isFinite(value) && (value ?? 0) > 0 ? value : fallback;
return Math.max(1, Math.round(resolved ?? 1));
}
@@ -477,7 +468,9 @@ export function useImageCanvasGenerationSubmissionWorkflow({
const applyQuickEditResultToSourceLayer = useCallback(
(
generated: Parameters<typeof applyImageEditResultToSourceLayer>[0]['generated'],
generated: Parameters<
typeof applyImageEditResultToSourceLayer
>[0]['generated'],
sourceLayer: CanvasLayer,
generationInputs: CanvasGenerationInputs,
dialogId?: string,
@@ -746,17 +739,22 @@ export function useImageCanvasGenerationSubmissionWorkflow({
}
try {
let sourceImageSrc = await resolveEditorImageReferenceDataUrl(
sourceLayer.objectKey?.trim() || sourceLayer.src,
);
let sourceImageSrc = sourceLayer.objectKey?.trim() || sourceLayer.src;
if (options.marks?.length) {
sourceImageSrc =
await resolveEditorImageReferenceDataUrl(sourceImageSrc);
sourceImageSrc = await renderUiDesignAssetExtractionMarkedImage({
source: sourceImageSrc,
marks: options.marks,
});
}
const generationInputs =
buildUiDesignAssetExtractionGenerationInputs(sourceLayer);
const generationInputs = buildUiDesignAssetExtractionGenerationInputs(
sourceLayer,
options.references,
);
const referenceImageSrcs = (options.references ?? [])
.map((reference) => resolveImageReferenceSubmissionSource(reference))
.filter((source) => source.trim().length > 0);
const extractionPlan = resolveUiAssetExtractionGenerationPlan(
options.marks?.length ?? 0,
);
@@ -774,6 +772,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
model: normalizeEditorImageModel(
options.model ?? DEFAULT_IMAGE_MODEL,
),
...(referenceImageSrcs.length ? { referenceImageSrcs } : {}),
projectId,
generationInputs,
assetFolderId,
@@ -877,13 +876,11 @@ export function useImageCanvasGenerationSubmissionWorkflow({
});
try {
const iconGenerationInput =
await normalizeIconSpritesheetReferenceImage(submissionPlan.input);
const canvasCompletionPlaceholder =
getGeneratingDialogPlaceholder(dialog);
const generated = await runEditorGenerationWithWalletRefresh(
generateEditorIconSpritesheet({
...iconGenerationInput,
...submissionPlan.input,
projectId,
generationInputs: submissionPlan.generationInputs,
assetFolderId,
@@ -972,10 +969,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
const basePrompt =
quickEditPanel.prompt.trim() ||
(panelMode === 'redraw' ? '重绘图片' : '快速编辑图片');
const normalizedPrompt =
panelMode === 'redraw'
? basePrompt
: basePrompt;
const normalizedPrompt = panelMode === 'redraw' ? basePrompt : basePrompt;
let quickEditDialogId: string | undefined;
if (panelMode === 'quick-edit') {
const quickEditDraft = createQuickEditGenerationDialogDraft({
@@ -126,14 +126,19 @@ function findSourceGenerationDialog(
dialogs: CanvasGenerationDialogState[],
sourceLayer: CanvasLayer,
) {
return [...dialogs].reverse().find(
(dialog) =>
dialog.generatedLayerId === sourceLayer.id ||
(!dialog.generatedLayerId && dialog.sourceLayerId === sourceLayer.id),
);
return [...dialogs]
.reverse()
.find(
(dialog) =>
dialog.generatedLayerId === sourceLayer.id ||
(!dialog.generatedLayerId && dialog.sourceLayerId === sourceLayer.id),
);
}
function findSourceAnimationLayer(layers: CanvasLayer[], sourceLayer: CanvasLayer) {
function findSourceAnimationLayer(
layers: CanvasLayer[],
sourceLayer: CanvasLayer,
) {
if (sourceLayer.assetKind !== 'character-animation') {
return null;
}
@@ -144,7 +149,8 @@ function findSourceAnimationLayer(layers: CanvasLayer[], sourceLayer: CanvasLaye
return (
layers.find(
(layer) =>
layer.resourceId === sourceResourceId && layer.assetKind === 'character',
layer.resourceId === sourceResourceId &&
layer.assetKind === 'character',
) ?? null
);
}
@@ -1679,6 +1685,51 @@ export function useImageCanvasGenerationWorkflow({
[rememberImageModel],
);
const appendUiAssetExtractionReferences = useCallback(
(references: CharacterReferenceImage[]) => {
if (!references.length) {
return;
}
setUiAssetExtractionState((currentState) =>
currentState
? {
...currentState,
references: [...currentState.references, ...references],
status:
currentState.status === 'failed' ? 'idle' : currentState.status,
errorMessage:
currentState.status === 'failed'
? undefined
: currentState.errorMessage,
}
: currentState,
);
},
[],
);
const removeUiAssetExtractionReference = useCallback(
(referenceId: string) => {
setUiAssetExtractionState((currentState) =>
currentState
? {
...currentState,
references: currentState.references.filter(
(reference) => reference.id !== referenceId,
),
status:
currentState.status === 'failed' ? 'idle' : currentState.status,
errorMessage:
currentState.status === 'failed'
? undefined
: currentState.errorMessage,
}
: currentState,
);
},
[],
);
const changeQuickEditSelectionTool = useCallback(
(tool: UiAssetExtractionTool | null) => {
updateQuickEditSelectionState((currentState) =>
@@ -1870,6 +1921,7 @@ export function useImageCanvasGenerationWorkflow({
await submitUiDesignAssetExtraction(sourceLayer, {
marks: currentState.marks,
model: currentState.model,
references: currentState.references,
suppressAlert: true,
});
setUiAssetExtractionState(null);
@@ -1998,6 +2050,8 @@ export function useImageCanvasGenerationWorkflow({
quickEditSelectionSourceLayer,
changeUiAssetExtractionTool,
changeUiAssetExtractionModel,
appendUiAssetExtractionReferences,
removeUiAssetExtractionReference,
changeQuickEditSelectionTool,
startUiAssetExtractionPointer,
startQuickEditSelectionPointer,
@@ -2097,6 +2151,8 @@ export function useImageCanvasGenerationWorkflow({
quickEditSelectionSourceLayer,
changeUiAssetExtractionTool,
changeUiAssetExtractionModel,
appendUiAssetExtractionReferences,
removeUiAssetExtractionReference,
changeQuickEditSelectionTool,
startUiAssetExtractionPointer,
startQuickEditSelectionPointer,
@@ -128,6 +128,8 @@ function UploadWorkflowHarness({
]);
const [assets, setAssets] = useState<EditorAsset[]>([]);
const [layers, setLayers] = useState<CanvasLayer[]>([]);
const [uiAssetExtractionReferences, setUiAssetExtractionReferences] =
useState<NonNullable<GenerateDialogState['generationReferences']>>([]);
const [generateDialog, setGenerateDialog] =
useState<GenerateDialogState | null>(null);
const [, setQuickEditPanel] = useState<QuickEditPanelState | null>(null);
@@ -153,6 +155,11 @@ function UploadWorkflowHarness({
setLayers,
setGenerateDialog,
setQuickEditPanel,
appendUiAssetExtractionReferences: (references) =>
setUiAssetExtractionReferences((currentReferences) => [
...currentReferences,
...references,
]),
appendCanvasLayersWithResources: (nextLayers) => {
setLayers((currentLayers) => [...currentLayers, ...nextLayers]);
},
@@ -199,6 +206,14 @@ function UploadWorkflowHarness({
? `${generateDialog.mode}:${generateDialog.status}:${generateDialog.characterSpecReference?.label ?? '-'}:${generateDialog.characterReferences?.length ?? 0}:${generateDialog.iconSpecReference?.label ?? '-'}:${generateDialog.specReference?.label ?? '-'}:${generateDialog.generationReferences?.length ?? 0}:${generateDialog.generationReferences?.[0]?.label ?? '-'}:${generateDialog.generationReferences?.map((reference) => `${reference.mediaType ?? 'image'}:${reference.src}:${reference.objectKey ?? '-'}:${reference.resourceId ?? '-'}:${reference.sourceAssetId ?? '-'}`).join(',') || '-'}`
: '-'}
</span>
<span data-testid="ui-asset-extraction-references">
{uiAssetExtractionReferences
.map(
(reference) =>
`${reference.label}:${reference.src}:${reference.objectKey ?? '-'}:${reference.resourceId ?? '-'}`,
)
.join('|') || '-'}
</span>
<button
type="button"
onClick={() => workflow.addUploadedFiles([createTestFile()])}
@@ -292,6 +307,14 @@ function UploadWorkflowHarness({
>
选择生成参考图
</button>
<button
type="button"
onClick={() =>
workflow.setUploadTarget('ui-asset-extraction-reference')
}
>
选择UI素材提取参考图
</button>
<button
type="button"
onClick={() => workflow.setUploadTarget('spec-reference')}
@@ -380,13 +403,15 @@ describe('useImageCanvasUploadWorkflow', () => {
});
createdMediaElements = [];
const originalCreateElement = document.createElement.bind(document);
vi.spyOn(document, 'createElement').mockImplementation((tagName, options) => {
const element = originalCreateElement(tagName, options);
if (tagName === 'video' || tagName === 'audio') {
createdMediaElements.push(element as HTMLMediaElement);
}
return element;
});
vi.spyOn(document, 'createElement').mockImplementation(
(tagName, options) => {
const element = originalCreateElement(tagName, options);
if (tagName === 'video' || tagName === 'audio') {
createdMediaElements.push(element as HTMLMediaElement);
}
return element;
},
);
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
value: vi.fn(() => 'blob:media'),
@@ -395,7 +420,9 @@ describe('useImageCanvasUploadWorkflow', () => {
configurable: true,
value: vi.fn(),
});
vi.spyOn(HTMLMediaElement.prototype, 'load').mockImplementation(() => undefined);
vi.spyOn(HTMLMediaElement.prototype, 'load').mockImplementation(
() => undefined,
);
});
it('opens login before creating placeholders and resumes the same upload after login', async () => {
@@ -541,9 +568,7 @@ describe('useImageCanvasUploadWorkflow', () => {
it('uses the uploaded image resolution before creating the canvas layer and persisted asset', async () => {
render(<UploadWorkflowHarness />);
fireEvent.click(
screen.getByRole('button', { name: '上传原始尺寸到画布' }),
);
fireEvent.click(screen.getByRole('button', { name: '上传原始尺寸到画布' }));
await waitFor(() => {
expect(createEditorAssetMock).toHaveBeenCalledWith(
@@ -655,6 +680,41 @@ describe('useImageCanvasUploadWorkflow', () => {
expect(createEditorAssetMock).not.toHaveBeenCalled();
});
it('dispatches file input uploads to UI asset extraction references', async () => {
render(<UploadWorkflowHarness />);
fireEvent.click(
screen.getByRole('button', { name: '选择UI素材提取参考图' }),
);
fireEvent.change(screen.getByLabelText('上传图片文件'), {
target: {
files: [createTestFile('UI素材参考.png')],
},
});
await waitFor(() => {
expect(
screen.getByTestId('ui-asset-extraction-references').textContent,
).toContain(
'UI素材参考.png:/generated-character-drafts/editor/asset-library/image/UI素材参考.png:generated-character-drafts/editor/asset-library/image/UI素材参考.png',
);
});
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
'project-1',
expect.objectContaining({
imageSrc:
'/generated-character-drafts/editor/asset-library/image/UI素材参考.png',
objectKey:
'generated-character-drafts/editor/asset-library/image/UI素材参考.png',
assetObjectId: 'assetobj-editor-image',
width: 420,
height: 315,
sourceType: 'uploaded',
}),
);
expect(createEditorAssetMock).not.toHaveBeenCalled();
});
it('dispatches file input uploads to spec reference images', async () => {
render(<UploadWorkflowHarness />);
@@ -725,7 +785,9 @@ describe('useImageCanvasUploadWorkflow', () => {
}),
);
expect(createEditorAssetMock).not.toHaveBeenCalled();
expect(screen.getByTestId('dialog').textContent).not.toContain('data:video');
expect(screen.getByTestId('dialog').textContent).not.toContain(
'data:video',
);
});
it('uploads Seedance reference audios through OSS and stores object keys instead of data URLs', async () => {
@@ -765,6 +827,8 @@ describe('useImageCanvasUploadWorkflow', () => {
}),
);
expect(createEditorAssetMock).not.toHaveBeenCalled();
expect(screen.getByTestId('dialog').textContent).not.toContain('data:audio');
expect(screen.getByTestId('dialog').textContent).not.toContain(
'data:audio',
);
});
});
@@ -92,6 +92,9 @@ type UseImageCanvasUploadWorkflowOptions = {
setLayers: Dispatch<SetStateAction<CanvasLayer[]>>;
setGenerateDialog: Dispatch<SetStateAction<GenerateDialogState | null>>;
setQuickEditPanel: Dispatch<SetStateAction<QuickEditPanelState | null>>;
appendUiAssetExtractionReferences: (
references: CharacterReferenceImage[],
) => void;
appendCanvasLayersWithResources: (nextLayers: CanvasLayer[]) => void;
selectSingleLayer: (layerId: string | null) => void;
};
@@ -128,6 +131,7 @@ export function useImageCanvasUploadWorkflow({
setLayers,
setGenerateDialog,
setQuickEditPanel,
appendUiAssetExtractionReferences,
appendCanvasLayersWithResources,
selectSingleLayer,
}: UseImageCanvasUploadWorkflowOptions) {
@@ -135,7 +139,9 @@ export function useImageCanvasUploadWorkflow({
const canAccessProtectedDataRef = useRef(canAccessProtectedData);
const uploadTargetRef = useRef<UploadTarget>('asset');
const uploadRequestOptionsRef = useRef<UploadRequestOptions>({});
const uploadAssetFolderCreatePromiseRef = useRef<Promise<string> | null>(null);
const uploadAssetFolderCreatePromiseRef = useRef<Promise<string> | null>(
null,
);
const [uploadTarget, setUploadTargetState] = useState<UploadTarget>('asset');
canAccessProtectedDataRef.current = canAccessProtectedData;
@@ -464,6 +470,38 @@ export function useImageCanvasUploadWorkflow({
[persistReferenceUploads, setQuickEditPanel],
);
const addUiAssetExtractionReferenceFiles = useCallback(
async (files: FileList | File[]) => {
const imageFiles = Array.from(files).filter(isImageFile);
if (!imageFiles.length) {
window.alert('请选择图片文件');
return;
}
const references = await Promise.all(
imageFiles.map(async (file, index) => ({
file,
index,
imageSrc: await readImageFileAsDataUrl(file),
})),
);
const persistedReferences = await persistReferenceUploads(
references.map(({ file, imageSrc, index }) => ({
idPrefix: 'upload-ui-asset-extraction-reference',
index,
file,
fallbackLabel: `UI素材参考图${index + 1}`,
imageSrc,
})),
);
if (!persistedReferences) {
return;
}
appendUiAssetExtractionReferences(persistedReferences);
},
[appendUiAssetExtractionReferences, persistReferenceUploads],
);
const addPublicationReferenceFiles = useCallback(
async (files: FileList | File[]) => {
const imageFiles = Array.from(files).filter(isImageFile);
@@ -567,7 +605,10 @@ export function useImageCanvasUploadWorkflow({
}
const references = await Promise.all(
videoFiles.map(async (file, index) => {
const durationSeconds = await probeMediaDurationSeconds(file, 'video');
const durationSeconds = await probeMediaDurationSeconds(
file,
'video',
);
return {
file,
index,
@@ -579,7 +620,8 @@ export function useImageCanvasUploadWorkflow({
references.some(
({ durationSeconds }) =>
Number.isFinite(durationSeconds) &&
(durationSeconds < SEEDANCE_REFERENCE_FILE_LIMITS.mediaMinDurationSeconds ||
(durationSeconds <
SEEDANCE_REFERENCE_FILE_LIMITS.mediaMinDurationSeconds ||
durationSeconds >
SEEDANCE_REFERENCE_FILE_LIMITS.mediaMaxDurationSeconds),
)
@@ -595,8 +637,7 @@ export function useImageCanvasUploadWorkflow({
0,
);
if (
totalDuration >
SEEDANCE_REFERENCE_FILE_LIMITS.mediaTotalDurationSeconds
totalDuration > SEEDANCE_REFERENCE_FILE_LIMITS.mediaTotalDurationSeconds
) {
window.alert('参考视频总时长不能超过 15 秒');
return;
@@ -612,7 +653,10 @@ export function useImageCanvasUploadWorkflow({
try {
uploadedReferences = await Promise.all(
references.map(async ({ file, index, durationSeconds }) => {
const uploaded = await uploadEditorSeedanceReferenceFile(file, 'video');
const uploaded = await uploadEditorSeedanceReferenceFile(
file,
'video',
);
return {
file,
index,
@@ -629,7 +673,14 @@ export function useImageCanvasUploadWorkflow({
}
const persistedReferences = await persistReferenceUploads(
uploadedReferences.map(
({ file, src, index, durationSeconds, objectKey, assetObjectId }) => ({
({
file,
src,
index,
durationSeconds,
objectKey,
assetObjectId,
}) => ({
idPrefix: 'upload-video-reference-video',
index,
file,
@@ -675,7 +726,10 @@ export function useImageCanvasUploadWorkflow({
}
const references = await Promise.all(
audioFiles.map(async (file, index) => {
const durationSeconds = await probeMediaDurationSeconds(file, 'audio');
const durationSeconds = await probeMediaDurationSeconds(
file,
'audio',
);
return {
file,
index,
@@ -687,7 +741,8 @@ export function useImageCanvasUploadWorkflow({
references.some(
({ durationSeconds }) =>
Number.isFinite(durationSeconds) &&
(durationSeconds < SEEDANCE_REFERENCE_FILE_LIMITS.mediaMinDurationSeconds ||
(durationSeconds <
SEEDANCE_REFERENCE_FILE_LIMITS.mediaMinDurationSeconds ||
durationSeconds >
SEEDANCE_REFERENCE_FILE_LIMITS.mediaMaxDurationSeconds),
)
@@ -703,8 +758,7 @@ export function useImageCanvasUploadWorkflow({
0,
);
if (
totalDuration >
SEEDANCE_REFERENCE_FILE_LIMITS.mediaTotalDurationSeconds
totalDuration > SEEDANCE_REFERENCE_FILE_LIMITS.mediaTotalDurationSeconds
) {
window.alert('参考音频总时长不能超过 15 秒');
return;
@@ -720,7 +774,10 @@ export function useImageCanvasUploadWorkflow({
try {
uploadedReferences = await Promise.all(
references.map(async ({ file, index, durationSeconds }) => {
const uploaded = await uploadEditorSeedanceReferenceFile(file, 'audio');
const uploaded = await uploadEditorSeedanceReferenceFile(
file,
'audio',
);
return {
file,
index,
@@ -737,7 +794,14 @@ export function useImageCanvasUploadWorkflow({
}
const persistedReferences = await persistReferenceUploads(
uploadedReferences.map(
({ file, src, index, durationSeconds, objectKey, assetObjectId }) => ({
({
file,
src,
index,
durationSeconds,
objectKey,
assetObjectId,
}) => ({
idPrefix: 'upload-video-reference-audio',
index,
file,
@@ -881,7 +945,9 @@ export function useImageCanvasUploadWorkflow({
const uploadIndex = options.uploadIndex;
let imageDimensions: { width: number; height: number } | null = null;
if (mediaType === 'image') {
imageDimensions = await probeImageFileDimensions(file).catch(() => null);
imageDimensions = await probeImageFileDimensions(file).catch(
() => null,
);
}
const uploadedAsset = createUploadingAssetPlaceholder({
uploadIndex,
@@ -1074,11 +1140,7 @@ export function useImageCanvasUploadWorkflow({
});
});
},
[
allocateUploadIndex,
openEditorLoginModal,
uploadAssetFile,
],
[allocateUploadIndex, openEditorLoginModal, uploadAssetFile],
);
const openUploadPicker = useCallback(
@@ -1110,6 +1172,8 @@ export function useImageCanvasUploadWorkflow({
void addSpecReferenceFiles(files);
} else if (currentUploadTarget === 'generation-reference') {
void addGenerationReferenceFiles(files);
} else if (currentUploadTarget === 'ui-asset-extraction-reference') {
void addUiAssetExtractionReferenceFiles(files);
} else if (currentUploadTarget === 'quick-edit-reference') {
void addQuickEditReferenceFiles(files);
} else if (currentUploadTarget === 'publication-reference') {
@@ -1132,8 +1196,7 @@ export function useImageCanvasUploadWorkflow({
const requestOptions = uploadRequestOptionsRef.current;
addUploadedFiles(files, {
folderId: requestOptions.folderId,
addToCanvas:
requestOptions.addToCanvas ?? activeTool === 'upload',
addToCanvas: requestOptions.addToCanvas ?? activeTool === 'upload',
});
}
}
@@ -1146,6 +1209,7 @@ export function useImageCanvasUploadWorkflow({
addCharacterReferenceFiles,
addCharacterSpecReferenceFiles,
addGenerationReferenceFiles,
addUiAssetExtractionReferenceFiles,
addQuickEditReferenceFiles,
addPublicationReferenceFiles,
addVideoReferenceImageFiles,
@@ -1170,6 +1234,7 @@ export function useImageCanvasUploadWorkflow({
addCharacterSpecReferenceFiles,
addCharacterReferenceFiles,
addGenerationReferenceFiles,
addUiAssetExtractionReferenceFiles,
addQuickEditReferenceFiles,
addPublicationReferenceFiles,
addVideoReferenceImageFiles,

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