抽出上传参考图状态模型

扩展 ImageCanvasUploadModel 承载生成参考图上传后的状态转换

精简 useImageCanvasUploadWorkflow 中的参考图 dialog 写回逻辑

补充上传模型单测覆盖参考图写入和失败态清理

更新 TRACKING.md 记录第四十五执行批次验证
This commit is contained in:
2026-06-17 20:21:07 +08:00
parent 7dec8b7a66
commit 6afc1cf920
4 changed files with 272 additions and 51 deletions

View File

@@ -1,14 +1,18 @@
import { describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type {
CanvasLayer,
EditorAssetFolder,
GenerateDialogState,
} from './ImageCanvasEditorTypes';
import {
applyGenerationReferenceUpload,
createUploadCanvasLayer,
createUploadedGenerationReference,
createUploadingAssetPlaceholder,
resizeUploadCanvasLayerToImage,
resolveUploadFolderId,
setFailedGenerationIdle,
} from './ImageCanvasUploadModel';
function createFolder(
@@ -43,7 +47,23 @@ function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
};
}
function createDialog(
overrides: Partial<GenerateDialogState> = {},
): GenerateDialogState {
return {
mode: 'character',
prompt: '',
status: 'failed',
errorMessage: '旧错误',
...overrides,
};
}
describe('ImageCanvasUploadModel', () => {
afterEach(() => {
vi.useRealTimers();
});
it('resolves upload folder ids with project fallback', () => {
const folders = [
createFolder(),
@@ -89,6 +109,115 @@ describe('ImageCanvasUploadModel', () => {
});
});
it('creates uploaded generation references with fallback labels', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-06-17T12:00:00.000Z'));
expect(
createUploadedGenerationReference({
idPrefix: 'upload-character-reference',
index: 2,
fileName: '',
fallbackLabel: '参考图3',
imageSrc: 'data:image/png;base64,ref',
}),
).toEqual({
id: 'upload-character-reference-1781697600000-2',
label: '参考图3',
src: 'data:image/png;base64,ref',
});
});
it('resets failed generation dialogs when reference uploads are applied', () => {
expect(
setFailedGenerationIdle(
createDialog({ status: 'failed', errorMessage: '旧错误' }),
),
).toMatchObject({ status: 'idle', errorMessage: undefined });
expect(
setFailedGenerationIdle(createDialog({ status: 'generating' })),
).toMatchObject({ status: 'generating', errorMessage: '旧错误' });
});
it('applies uploaded references to matching generation dialogs', () => {
const reference = {
id: 'ref-a',
label: '参考图',
src: 'data:image/png;base64,ref',
};
expect(
applyGenerationReferenceUpload({
dialog: createDialog({ mode: 'spec' }),
target: 'spec-reference',
references: [reference],
}),
).toMatchObject({
mode: 'spec',
status: 'idle',
specReference: reference,
errorMessage: undefined,
});
expect(
applyGenerationReferenceUpload({
dialog: createDialog({ mode: 'character' }),
target: 'character-spec',
references: [reference],
}),
).toMatchObject({
mode: 'character',
status: 'idle',
characterSpecReference: reference,
});
expect(
applyGenerationReferenceUpload({
dialog: createDialog({ mode: 'icon' }),
target: 'icon-spec',
references: [reference],
}),
).toMatchObject({
mode: 'icon',
status: 'idle',
iconSpecReference: reference,
});
});
it('appends character reference uploads without changing unmatched dialogs', () => {
const previousReference = {
id: 'ref-old',
label: '旧参考图',
src: 'data:image/png;base64,old',
};
const nextReference = {
id: 'ref-new',
label: '新参考图',
src: 'data:image/png;base64,new',
};
expect(
applyGenerationReferenceUpload({
dialog: createDialog({
mode: 'character',
characterReferences: [previousReference],
}),
target: 'character-reference',
references: [nextReference],
}),
).toMatchObject({
mode: 'character',
status: 'idle',
characterReferences: [previousReference, nextReference],
});
const iconDialog = createDialog({ mode: 'icon' });
expect(
applyGenerationReferenceUpload({
dialog: iconDialog,
target: 'character-reference',
references: [nextReference],
}),
).toBe(iconDialog);
});
it('creates upload canvas layers centered on the target canvas point', () => {
const layer = createUploadCanvasLayer({
uploadIndex: 1,

View File

@@ -1,13 +1,20 @@
import { resolveLayerResolutionSize } from './ImageCanvasEditorModel';
import type {
CharacterReferenceImage,
CanvasLayer,
CanvasViewport,
EditorAsset,
EditorAssetFolder,
GenerateDialogState,
} from './ImageCanvasEditorTypes';
type CanvasSize = { width: number; height: number };
type CanvasPoint = { x: number; y: number };
type GenerationReferenceUploadTarget =
| 'character-reference'
| 'character-spec'
| 'icon-spec'
| 'spec-reference';
export const UPLOAD_LAYER_FALLBACK_SIZE = {
width: 420,
@@ -29,6 +36,85 @@ export function resolveUploadFolderId({
: 'project';
}
export function createUploadedGenerationReference({
idPrefix,
index,
fileName,
fallbackLabel,
imageSrc,
}: {
idPrefix: string;
index?: number;
fileName?: string;
fallbackLabel: string;
imageSrc: string;
}): CharacterReferenceImage {
return {
id:
typeof index === 'number'
? `${idPrefix}-${Date.now()}-${index}`
: `${idPrefix}-${Date.now()}`,
label: fileName || fallbackLabel,
src: imageSrc,
};
}
export function setFailedGenerationIdle(dialog: GenerateDialogState) {
return {
...dialog,
status: dialog.status === 'failed' ? 'idle' : dialog.status,
errorMessage: dialog.status === 'failed' ? undefined : dialog.errorMessage,
};
}
export function applyGenerationReferenceUpload({
dialog,
target,
references,
}: {
dialog: GenerateDialogState | null;
target: GenerationReferenceUploadTarget;
references: CharacterReferenceImage[];
}): GenerateDialogState | null {
const firstReference = references[0];
if (!firstReference) {
return dialog;
}
if (target === 'spec-reference') {
return dialog?.mode === 'spec'
? {
...setFailedGenerationIdle(dialog),
specReference: firstReference,
}
: dialog;
}
if (target === 'character-spec') {
return dialog?.mode === 'character'
? {
...setFailedGenerationIdle(dialog),
characterSpecReference: firstReference,
}
: dialog;
}
if (target === 'icon-spec') {
return dialog?.mode === 'icon'
? {
...setFailedGenerationIdle(dialog),
iconSpecReference: firstReference,
}
: dialog;
}
return dialog?.mode === 'character'
? {
...setFailedGenerationIdle(dialog),
characterReferences: [
...(dialog.characterReferences ?? []),
...references,
],
}
: dialog;
}
export function createUploadingAssetPlaceholder({
uploadIndex,
fileName,

View File

@@ -20,7 +20,9 @@ import type {
} from './ImageCanvasEditorTypes';
import { isImageFile, readImageFileAsDataUrl } from './ImageCanvasFileModel';
import {
applyGenerationReferenceUpload,
createUploadCanvasLayer,
createUploadedGenerationReference,
createUploadingAssetPlaceholder,
resizeUploadCanvasLayerToImage,
resolveUploadFolderId,
@@ -60,14 +62,6 @@ function isEditorAuthError(error: unknown) {
);
}
function setFailedGenerationIdle(dialog: GenerateDialogState) {
return {
...dialog,
status: dialog.status === 'failed' ? 'idle' : dialog.status,
errorMessage: dialog.status === 'failed' ? undefined : dialog.errorMessage,
};
}
export function useImageCanvasUploadWorkflow({
canAccessProtectedData,
openEditorLoginModal,
@@ -113,16 +107,18 @@ export function useImageCanvasUploadWorkflow({
const imageSrc = await readImageFileAsDataUrl(imageFile);
setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'character'
? {
...setFailedGenerationIdle(currentDialog),
characterSpecReference: {
id: `upload-character-spec-${Date.now()}`,
label: imageFile.name || '角色形象规范',
src: imageSrc,
},
}
: currentDialog,
applyGenerationReferenceUpload({
dialog: currentDialog,
target: 'character-spec',
references: [
createUploadedGenerationReference({
idPrefix: 'upload-character-spec',
fileName: imageFile.name,
fallbackLabel: '角色形象规范',
imageSrc,
}),
],
}),
);
},
[setGenerateDialog],
@@ -138,16 +134,18 @@ export function useImageCanvasUploadWorkflow({
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,
applyGenerationReferenceUpload({
dialog: currentDialog,
target: 'spec-reference',
references: [
createUploadedGenerationReference({
idPrefix: 'upload-spec-reference',
fileName: imageFile.name,
fallbackLabel: '参考图',
imageSrc,
}),
],
}),
);
},
[setGenerateDialog],
@@ -163,21 +161,25 @@ export function useImageCanvasUploadWorkflow({
const references = await Promise.all(
imageFiles.map(async (file, index) => ({
id: `upload-character-reference-${Date.now()}-${index}`,
label: file.name || `参考图${index + 1}`,
src: await readImageFileAsDataUrl(file),
file,
index,
imageSrc: await readImageFileAsDataUrl(file),
})),
);
setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'character'
? {
...setFailedGenerationIdle(currentDialog),
characterReferences: [
...(currentDialog.characterReferences ?? []),
...references,
],
}
: currentDialog,
applyGenerationReferenceUpload({
dialog: currentDialog,
target: 'character-reference',
references: references.map(({ file, imageSrc, index }) =>
createUploadedGenerationReference({
idPrefix: 'upload-character-reference',
index,
fileName: file.name,
fallbackLabel: `参考图${index + 1}`,
imageSrc,
}),
),
}),
);
},
[setGenerateDialog],
@@ -193,16 +195,18 @@ export function useImageCanvasUploadWorkflow({
const imageSrc = await readImageFileAsDataUrl(imageFile);
setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'icon'
? {
...setFailedGenerationIdle(currentDialog),
iconSpecReference: {
id: `upload-icon-spec-${Date.now()}`,
label: imageFile.name || '图标素材规范',
src: imageSrc,
},
}
: currentDialog,
applyGenerationReferenceUpload({
dialog: currentDialog,
target: 'icon-spec',
references: [
createUploadedGenerationReference({
idPrefix: 'upload-icon-spec',
fileName: imageFile.name,
fallbackLabel: '图标素材规范',
imageSrc,
}),
],
}),
);
},
[setGenerateDialog],