支持规范参考图输入

为角色形象规范、UI素材规范、自定义规范面板新增参考图上传入口。

生成规范时携带参考图并自动追加参考图生成规范语义。

补充生成流程和上传流程回归测试。

更新画板角色形象生成入口设计文档。
This commit is contained in:
2026-06-17 14:20:23 +08:00
parent f8e063a878
commit 05a47816b0
6 changed files with 173 additions and 8 deletions

View File

@@ -50,6 +50,13 @@
- 角色图生成完成后,后端必须先对返回图片执行绿幕 / 近白背景去背,并统一输出透明背景 PNG随后写入 OSS 私有对象,并确认 `asset_object`。接口回包仍返回透明 PNG Data URL 供画板立即显示,同时返回 `objectKey` / `assetObjectId`,前端创建图层和画板资源记录时必须保存这两个字段。
## 生成规范参考图
- `生成规范 -> 角色形象规范``UI素材规范``自定义规范` 的设定面板支持上传 1 张参考图;`图标素材规范` 继续使用后续图标素材生成面板里的专用规范图链路,不在这里重复新增入口。
- 参考图入口只展示字段标题、缩略图或上传图标、文件名,不把参考规则说明铺在 UI 上。
- 提交生成规范时,若存在参考图,前端必须把参考图作为 `referenceImageSrcs[0]` 提交到 `/api/editor/images/generations`,并在生图提示词开头自动追加“参考图生成规范”语义:要求模型参考图 1 的构图、风格、材质、色彩、形状语言和视觉层级生成规范图,但不要复制参考图中的文字、水印或无关背景。
- 生成结果的信息快照必须记录该参考图,标题为 `参考图`,便于后续在图片信息面板回看生成输入。
## 可访问性与状态
- 点选状态下画布显示状态提示 `请选择画布中的图片作为角色形象规范,按 Esc 退出`
@@ -121,4 +128,3 @@
- 每帧必须执行绿幕去背,输出透明背景 PNG。
- 抽帧结果写入 OSS并返回帧路径、帧尺寸、帧数、fps、预览视频路径、模型、价格和实际 prompt。
- 画板前端首版只展示生成完成结果摘要,不把帧序列自动铺到画布上;后续若要展示逐帧图层,必须继续复用画布图层与素材库资源模型。

View File

@@ -621,6 +621,37 @@ export function ImageCanvasGenerationComposerView({
) : null}
</>
)}
{generateDialog.specType !== 'icon' ? (
<div className="image-canvas-editor__field-block">
<PlatformFieldLabel
variant="form"
className="image-canvas-editor__field-title"
>
</PlatformFieldLabel>
<button
type="button"
className="image-canvas-editor__character-spec-ref image-canvas-editor__reference-tile image-canvas-editor__reference-tile--spec"
disabled={generateDialog.status === 'generating'}
onClick={() => onRequestUpload('spec-reference')}
>
<span className="image-canvas-editor__reference-tile-visual">
{generateDialog.specReference ? (
<img
src={generateDialog.specReference.src}
alt=""
aria-hidden="true"
/>
) : (
<ImagePlus className="h-4 w-4" aria-hidden="true" />
)}
</span>
<span className="image-canvas-editor__reference-tile-copy">
{generateDialog.specReference?.label ?? '添加参考图'}
</span>
</button>
</div>
) : null}
</div>
{generateDialog.status === 'failed' ? (
<PlatformStatusMessage

View File

@@ -155,6 +155,9 @@ function GenerationWorkflowHarness({
<button type="button" onClick={workflow.openGenerateDialog}>
</button>
<button type="button" onClick={() => workflow.openSpecDialog('ui')}>
UI规范
</button>
<button
type="button"
onClick={() =>
@@ -214,6 +217,25 @@ function GenerationWorkflowHarness({
>
</button>
<button
type="button"
onClick={() =>
dialogs.setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'spec'
? {
...currentDialog,
specReference: {
id: 'spec-ref',
label: '参考.png',
src: 'data:image/png;base64,ref',
},
}
: currentDialog,
)
}
>
</button>
<button
type="button"
onClick={() =>
@@ -307,6 +329,33 @@ describe('useImageCanvasGenerationWorkflow', () => {
);
});
it('submits spec generation with reference image and reference prompt semantics', async () => {
generateEditorImageMock.mockResolvedValueOnce(
createGenerated({ prompt: 'UI规范图' }),
);
render(<GenerationWorkflowHarness />);
fireEvent.click(screen.getByRole('button', { name: '打开UI规范' }));
fireEvent.click(screen.getByRole('button', { name: '添加规范参考图' }));
fireEvent.click(screen.getByRole('button', { name: '提交生成' }));
await waitFor(() => {
expect(generateEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
size: '2048x1152',
kind: 'spec',
referenceImageSrcs: ['data:image/png;base64,ref'],
prompt: expect.stringContaining('参考图生成规范'),
}),
);
});
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
'layer-generated-1:UI素材规范 1',
);
});
});
it('submits quick edits beside the source and fits source plus result', async () => {
generateEditorImageMock.mockResolvedValueOnce(
createGenerated({ prompt: '快速修图' }),
@@ -332,7 +381,7 @@ describe('useImageCanvasGenerationWorkflow', () => {
prompt: '快速修图',
size: '1024x768',
kind: 'quick-edit',
model: 'gpt-image-2',
model: 'gemini-3.1-flash-image-preview',
referenceImageSrcs: ['data:image/png;base64,source'],
});
});

View File

@@ -827,19 +827,30 @@ export function useImageCanvasGenerationWorkflow({
const specType = dialog.specType ?? 'custom';
const specValues =
dialog.specValues ?? DEFAULT_SPEC_FORM_VALUES[specType];
const specPrompt = buildSpecPrompt(specType, specValues);
const specPrompt = buildSpecPrompt(
specType,
specValues,
Boolean(dialog.specReference?.src),
);
const generated = await generateEditorImage({
prompt: specPrompt,
size: SPEC_GENERATION_SIZE,
model: DEFAULT_IMAGE_MODEL,
kind: 'spec',
...(dialog.specReference?.src
? { referenceImageSrcs: [dialog.specReference.src] }
: {}),
});
addGeneratedResultLayer(generated, {
frame: getGeneratingDialogPlaceholder(dialog),
assetKind: specType === 'icon' ? 'icon-spec' : 'spec',
title: `${SPEC_TYPE_LABEL[specType]} ${layerCounterRef.current + 1}`,
dialogId: canvasDialog?.id,
generationInputs: buildSpecGenerationInputs(specType, specValues),
generationInputs: buildSpecGenerationInputs(
specType,
specValues,
dialog.specReference,
),
});
} else if (dialog.mode === 'character') {
const referenceImageSrcs = [

View File

@@ -132,7 +132,7 @@ function UploadWorkflowHarness({
<span data-testid="selected-layer">{selectedLayerId ?? '-'}</span>
<span data-testid="dialog">
{generateDialog
? `${generateDialog.mode}:${generateDialog.status}:${generateDialog.characterSpecReference?.label ?? '-'}:${generateDialog.characterReferences?.length ?? 0}:${generateDialog.iconSpecReference?.label ?? '-'}`
? `${generateDialog.mode}:${generateDialog.status}:${generateDialog.characterSpecReference?.label ?? '-'}:${generateDialog.characterReferences?.length ?? 0}:${generateDialog.iconSpecReference?.label ?? '-'}:${generateDialog.specReference?.label ?? '-'}`
: '-'}
</span>
<button
@@ -168,6 +168,26 @@ function UploadWorkflowHarness({
>
</button>
<button
type="button"
onClick={() =>
setGenerateDialog({
mode: 'spec',
specType: 'ui',
prompt: '',
status: 'failed',
errorMessage: '旧错误',
})
}
>
</button>
<button
type="button"
onClick={() => workflow.setUploadTarget('spec-reference')}
>
</button>
<button
type="button"
onClick={() => workflow.setUploadTarget('character-spec')}
@@ -352,7 +372,7 @@ describe('useImageCanvasUploadWorkflow', () => {
fireEvent.click(screen.getByRole('button', { name: '选择角色规范' }));
await waitFor(() => {
expect(screen.getByTestId('dialog').textContent).toBe(
'character:failed:-:0:-',
'character:failed:-:0:-:-',
);
});
@@ -364,7 +384,26 @@ describe('useImageCanvasUploadWorkflow', () => {
await waitFor(() => {
expect(screen.getByTestId('dialog').textContent).toContain(
'character:idle:角色规范.png:0:-',
'character:idle:角色规范.png:0:-:-',
);
});
expect(createEditorAssetMock).not.toHaveBeenCalled();
});
it('dispatches file input uploads to spec reference images', async () => {
render(<UploadWorkflowHarness />);
fireEvent.click(screen.getByRole('button', { name: '准备规范生成' }));
fireEvent.click(screen.getByRole('button', { name: '选择规范参考图' }));
fireEvent.change(screen.getByLabelText('上传图片文件'), {
target: {
files: [createTestFile('UI参考.png')],
},
});
await waitFor(() => {
expect(screen.getByTestId('dialog').textContent).toContain(
'spec:idle:-:0:-:UI参考.png',
);
});
expect(createEditorAssetMock).not.toHaveBeenCalled();

View File

@@ -126,6 +126,31 @@ export function useImageCanvasUploadWorkflow({
[setGenerateDialog],
);
const addSpecReferenceFiles = useCallback(
async (files: FileList | File[]) => {
const imageFile = Array.from(files).find(isImageFile);
if (!imageFile) {
window.alert('请选择图片文件');
return;
}
const imageSrc = await readImageFileAsDataUrl(imageFile);
setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'spec'
? {
...setFailedGenerationIdle(currentDialog),
specReference: {
id: `upload-spec-reference-${Date.now()}`,
label: imageFile.name || '参考图',
src: imageSrc,
},
}
: currentDialog,
);
},
[setGenerateDialog],
);
const addCharacterReferenceFiles = useCallback(
async (files: FileList | File[]) => {
const imageFiles = Array.from(files).filter(isImageFile);
@@ -485,7 +510,9 @@ export function useImageCanvasUploadWorkflow({
const files = event.currentTarget.files;
const currentUploadTarget = uploadTargetRef.current;
if (files?.length) {
if (currentUploadTarget === 'character-spec') {
if (currentUploadTarget === 'spec-reference') {
void addSpecReferenceFiles(files);
} else if (currentUploadTarget === 'character-spec') {
void addCharacterSpecReferenceFiles(files);
} else if (currentUploadTarget === 'character-reference') {
void addCharacterReferenceFiles(files);
@@ -502,6 +529,7 @@ export function useImageCanvasUploadWorkflow({
activeTool,
addCharacterReferenceFiles,
addCharacterSpecReferenceFiles,
addSpecReferenceFiles,
addIconSpecReferenceFiles,
addUploadedFiles,
setUploadTarget,
@@ -515,6 +543,7 @@ export function useImageCanvasUploadWorkflow({
requestUpload,
handleUploadInputChange,
addUploadedFiles,
addSpecReferenceFiles,
addCharacterSpecReferenceFiles,
addCharacterReferenceFiles,
addIconSpecReferenceFiles,