抽出上传参考图状态模型
扩展 ImageCanvasUploadModel 承载生成参考图上传后的状态转换 精简 useImageCanvasUploadWorkflow 中的参考图 dialog 写回逻辑 补充上传模型单测覆盖参考图写入和失败态清理 更新 TRACKING.md 记录第四十五执行批次验证
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user