支持素材生成上传参考图
图标素材生成支持上传普通参考图并提交到后端参考图数组。 UI设计图生成和UI素材提取支持上传参考图并写入生成输入记录。 同步前后端契约、OpenAPI、文档和相关单测。
This commit is contained in:
@@ -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
Reference in New Issue
Block a user