合并主分支
同步主线图标规范、生成参数恢复与统一画布生成链路 保留快速编辑的权威引用 ID、素材类型门禁与目标对象一致性校验 补齐 SpacetimeDB 引用解析、V2 引用槽位与相关测试门禁
This commit is contained in:
@@ -573,6 +573,35 @@ describe('creationShowcaseModel', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('infers video cost from typed V2 duration metadata', () => {
|
||||
const items = buildCreationShowcaseItems({
|
||||
activeTab: 'all',
|
||||
projectResources: [
|
||||
createResource({
|
||||
resourceId: 'video-v2',
|
||||
label: 'V2 角色宣传片',
|
||||
assetKind: 'video',
|
||||
imageSrc: '/generated-editor-videos/video-v2.mp4',
|
||||
model: 'seedance2.0-fast',
|
||||
generationInputs: {
|
||||
version: 2,
|
||||
action: 'video.generate',
|
||||
fields: [
|
||||
{ id: 'resolution', title: '清晰度', value: '720p' },
|
||||
{ id: 'durationSeconds', title: '时长', value: 6 },
|
||||
],
|
||||
references: [],
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]?.cost).toBe(
|
||||
`${calculateEditorVideoPrice('seedance2.0-fast', '720p', 6)}泥点`,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps character animation videos out of the marketing tab', () => {
|
||||
const items = buildCreationShowcaseItems({
|
||||
activeTab: 'marketing',
|
||||
|
||||
@@ -21,10 +21,7 @@ import {
|
||||
export type ShowcaseTabId = 'all' | 'characters' | 'ui' | 'music' | 'marketing';
|
||||
|
||||
export type ShowcaseAssetMediaType =
|
||||
| 'image'
|
||||
| 'video'
|
||||
| 'audio'
|
||||
| 'image-sequence';
|
||||
'image' | 'video' | 'audio' | 'image-sequence';
|
||||
|
||||
export type ShowcaseAssetPreview = {
|
||||
id: string;
|
||||
@@ -657,7 +654,7 @@ function getGroupsForTab(assets: EditorAssetSnapshot[], tab: ShowcaseTabId) {
|
||||
function resolveUserGenerationPrompt(asset: EditorAssetSnapshot) {
|
||||
const fields = asset.generationInputs?.fields ?? [];
|
||||
const promptField = fields.find((field) => {
|
||||
const value = field.value.trim();
|
||||
const value = String(field.value).trim();
|
||||
return (
|
||||
value &&
|
||||
USER_PROMPT_INPUT_TITLES.has(
|
||||
@@ -666,7 +663,7 @@ function resolveUserGenerationPrompt(asset: EditorAssetSnapshot) {
|
||||
!isBuiltInPromptText(value)
|
||||
);
|
||||
});
|
||||
const promptValue = promptField?.value.trim();
|
||||
const promptValue = promptField ? String(promptField.value).trim() : '';
|
||||
if (promptValue) {
|
||||
return promptValue;
|
||||
}
|
||||
@@ -679,7 +676,7 @@ function resolveUserGenerationPrompt(asset: EditorAssetSnapshot) {
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
const value = field.value.trim();
|
||||
const value = String(field.value).trim();
|
||||
return value && !isBuiltInPromptText(value)
|
||||
? [`${field.title.trim()}:${value}`]
|
||||
: [];
|
||||
@@ -837,7 +834,14 @@ function inferMediaDurationSeconds(asset: EditorAssetSnapshot) {
|
||||
const title = field.title.trim().toLowerCase();
|
||||
return title === '时长' || title === 'duration';
|
||||
});
|
||||
const matchedValue = durationField?.value.match(/\d+(?:\.\d+)?/u)?.[0];
|
||||
const durationValue = durationField?.value;
|
||||
if (typeof durationValue === 'number') {
|
||||
return Number.isFinite(durationValue) ? durationValue : undefined;
|
||||
}
|
||||
if (typeof durationValue !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
const matchedValue = durationValue.match(/\d+(?:\.\d+)?/u)?.[0];
|
||||
return matchedValue ? Number(matchedValue) : undefined;
|
||||
}
|
||||
|
||||
@@ -849,7 +853,7 @@ function inferVideoResolution(
|
||||
const fields = asset.generationInputs?.fields ?? [];
|
||||
const fieldValue = fields
|
||||
.flatMap((field) => [field.title, field.value])
|
||||
.map((value) => value.trim())
|
||||
.map((value) => String(value).trim())
|
||||
.find((value) => /\b(?:480p|720p|1080p)\b/iu.test(value));
|
||||
const matchedValue = fieldValue?.match(/\b(480p|720p|1080p)\b/iu)?.[1];
|
||||
if (matchedValue && allowedValues.includes(matchedValue)) {
|
||||
@@ -957,9 +961,8 @@ function toLibraryShowcaseItem(
|
||||
const sortedAssets = sortGroupAssets(group.assets);
|
||||
const previews = sortedAssets
|
||||
.map(toAssetPreview)
|
||||
.filter(
|
||||
(preview): preview is ShowcaseAssetPreview =>
|
||||
Boolean(preview?.src.trim()),
|
||||
.filter((preview): preview is ShowcaseAssetPreview =>
|
||||
Boolean(preview?.src.trim()),
|
||||
);
|
||||
if (!previews.length) {
|
||||
return null;
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from './ImageCanvasEditorView.test-utils';
|
||||
|
||||
const generateEditorImageMock = vi.hoisted(() => vi.fn());
|
||||
const generateEditorIconSpecMock = vi.hoisted(() => vi.fn());
|
||||
const generateEditorIconSpritesheetMock = vi.hoisted(() => vi.fn());
|
||||
const extractEditorUiDesignAssetsMock = vi.hoisted(() => vi.fn());
|
||||
const renderUiDesignAssetExtractionMarkedImageMock = vi.hoisted(() => vi.fn());
|
||||
@@ -57,6 +58,7 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => {
|
||||
deleteEditorAsset: deleteEditorAssetMock,
|
||||
deleteEditorAssetFolder: deleteEditorAssetFolderMock,
|
||||
generateEditorCharacterAnimation: generateEditorCharacterAnimationMock,
|
||||
generateEditorIconSpec: generateEditorIconSpecMock,
|
||||
generateEditorIconSpritesheet: generateEditorIconSpritesheetMock,
|
||||
generateEditorImage: generateEditorImageMock,
|
||||
loadEditorAssetLibrary: loadEditorAssetLibraryMock,
|
||||
@@ -109,6 +111,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
generateEditorIconSpecMock.mockReset();
|
||||
renderUiDesignAssetExtractionMarkedImageMock.mockReset();
|
||||
renderUiDesignAssetExtractionMarkedImageMock.mockResolvedValue(
|
||||
'data:image/png;base64,bWFya2VkLXVpLWRlc2lnbg==',
|
||||
@@ -892,7 +895,15 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
});
|
||||
const submittedGenerationInputs =
|
||||
generateEditorImageMock.mock.calls[0]?.[0]?.generationInputs;
|
||||
expect(JSON.stringify(submittedGenerationInputs)).not.toContain('pixelArt');
|
||||
expect(submittedGenerationInputs).toEqual(
|
||||
expect.objectContaining({
|
||||
version: 2,
|
||||
action: 'image.generate',
|
||||
fields: expect.arrayContaining([
|
||||
expect.objectContaining({ id: 'style', value: 'pixelArt' }),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy();
|
||||
@@ -2174,19 +2185,29 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
expect(screen.getByLabelText('角色生成占位图')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('builds UI spec prompts from two fields and uses 2K landscape generation', async () => {
|
||||
generateEditorImageMock.mockImplementationOnce(async (input) =>
|
||||
createGeneratedImageResponse(input, {
|
||||
imageSrc: 'data:image/png;base64,c3BlYy11aQ==',
|
||||
width: 2048,
|
||||
height: 1152,
|
||||
sourceType: 'generated',
|
||||
prompt: 'UI规范提示词',
|
||||
actualPrompt: 'UI规范提示词',
|
||||
model: 'gpt-image-2',
|
||||
provider: 'VectorEngine',
|
||||
taskId: 'editor-spec-ui-1',
|
||||
}),
|
||||
it('submits icon spec business fields through the dedicated endpoint', async () => {
|
||||
generateEditorIconSpecMock.mockImplementationOnce(async (input) =>
|
||||
createGeneratedImageResponse(
|
||||
{
|
||||
...input,
|
||||
assetKind: 'icon-spec',
|
||||
kind: 'spec',
|
||||
model: 'gpt-image-2',
|
||||
aspectRatio: '16:9',
|
||||
imageSize: '2K',
|
||||
},
|
||||
{
|
||||
imageSrc: 'data:image/png;base64,c3BlYy11aQ==',
|
||||
width: 2048,
|
||||
height: 1152,
|
||||
sourceType: 'generated',
|
||||
prompt: 'UI规范提示词',
|
||||
actualPrompt: 'UI规范提示词',
|
||||
model: 'gpt-image-2',
|
||||
provider: 'VectorEngine',
|
||||
taskId: 'editor-spec-ui-1',
|
||||
},
|
||||
),
|
||||
);
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
@@ -2198,18 +2219,13 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
);
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '图标规范' }));
|
||||
|
||||
expect((screen.getByLabelText('玩法设定') as HTMLInputElement).value).toBe(
|
||||
'',
|
||||
expect(screen.getByLabelText('玩法设定').textContent).toBe('');
|
||||
expect(screen.getByLabelText('美术风格').textContent).toBe('');
|
||||
setPlainTextEditorValue(
|
||||
screen.getByLabelText('玩法设定'),
|
||||
'消除类派对玩法',
|
||||
);
|
||||
expect((screen.getByLabelText('美术风格') as HTMLInputElement).value).toBe(
|
||||
'',
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText('玩法设定'), {
|
||||
target: { value: '消除类派对玩法' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('美术风格'), {
|
||||
target: { value: '糖果玻璃拟物' },
|
||||
});
|
||||
setPlainTextEditorValue(screen.getByLabelText('美术风格'), '糖果玻璃拟物');
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('dialog', { name: '生成规范' })).getByRole(
|
||||
'button',
|
||||
@@ -2218,23 +2234,26 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(generateEditorImageMock).toHaveBeenCalledWith(
|
||||
expect(generateEditorIconSpecMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
kind: 'spec',
|
||||
model: 'gpt-image-2',
|
||||
size: '2048x1152',
|
||||
aspectRatio: '16:9',
|
||||
imageSize: '2K',
|
||||
prompt: expect.stringContaining('生成一张完整游戏UI规范汇总设定展板'),
|
||||
playSetting: '消除类派对玩法',
|
||||
artStyle: '糖果玻璃拟物',
|
||||
projectId: 'editor-project-default',
|
||||
assetFolderId: 'project',
|
||||
assetKind: 'icon-spec',
|
||||
generationInputs: {
|
||||
fields: [
|
||||
{ title: '玩法设定', value: '消除类派对玩法' },
|
||||
{ title: '美术风格', value: '糖果玻璃拟物' },
|
||||
],
|
||||
references: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
const prompt = generateEditorImageMock.mock.calls[0]?.[0]?.prompt ?? '';
|
||||
expect(prompt).toContain('玩法设定:消除类派对玩法');
|
||||
expect(prompt).toContain('美术风格:糖果玻璃拟物');
|
||||
const payload = generateEditorIconSpecMock.mock.calls[0]?.[0] ?? {};
|
||||
expect(payload).not.toHaveProperty('prompt');
|
||||
expect(payload).not.toHaveProperty('kind');
|
||||
expect(payload).not.toHaveProperty('assetKind');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByAltText(/画布图片:图标规范/)).toBeTruthy();
|
||||
@@ -2538,18 +2557,23 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
const characterGenerationInput = generateEditorImageMock.mock.calls[0]?.[0];
|
||||
expect(characterGenerationInput?.generationInputs).toEqual(
|
||||
expect.objectContaining({
|
||||
version: 2,
|
||||
action: 'character.generate',
|
||||
fields: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 'prompt',
|
||||
title: '角色设定',
|
||||
value: '银发游侠,蓝色披风,弓箭手,适合像素风战棋。',
|
||||
}),
|
||||
]),
|
||||
references: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 'specReference',
|
||||
title: '角色规范',
|
||||
label: '角色规范',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'reference',
|
||||
title: '常规参考图 1',
|
||||
label: '大鱼素材',
|
||||
}),
|
||||
@@ -2705,7 +2729,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
await waitFor(() => {
|
||||
expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
referenceImageSrc: 'resource-icon-spec',
|
||||
referenceId: 'resource-icon-spec',
|
||||
iconDescriptions: ['返回按钮\n设置按钮'],
|
||||
model: 'gemini-3.1-flash-image-preview',
|
||||
aspectRatio: '1:1',
|
||||
@@ -2713,8 +2737,11 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
projectId: 'editor-project-icons',
|
||||
assetFolderId: 'project',
|
||||
generationInputs: expect.objectContaining({
|
||||
version: 2,
|
||||
action: 'icon.generate',
|
||||
fields: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 'prompt',
|
||||
title: '素材描述',
|
||||
value: '返回按钮\n设置按钮',
|
||||
}),
|
||||
@@ -3074,18 +3101,28 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
});
|
||||
|
||||
it('allows icon asset generation to reference icon specs created from the renamed spec entry', async () => {
|
||||
generateEditorImageMock.mockImplementationOnce(async (input) =>
|
||||
createGeneratedImageResponse(input, {
|
||||
imageSrc: 'data:image/png;base64,cmVuYW1lZC1pY29uLXNwZWM=',
|
||||
width: 2048,
|
||||
height: 1152,
|
||||
sourceType: 'generated',
|
||||
prompt: '图标规范提示词',
|
||||
actualPrompt: '图标规范提示词',
|
||||
model: 'gpt-image-2',
|
||||
provider: 'VectorEngine',
|
||||
taskId: 'editor-icon-spec-renamed-1',
|
||||
}),
|
||||
generateEditorIconSpecMock.mockImplementationOnce(async (input) =>
|
||||
createGeneratedImageResponse(
|
||||
{
|
||||
...input,
|
||||
assetKind: 'icon-spec',
|
||||
kind: 'spec',
|
||||
model: 'gpt-image-2',
|
||||
aspectRatio: '16:9',
|
||||
imageSize: '2K',
|
||||
},
|
||||
{
|
||||
imageSrc: 'data:image/png;base64,cmVuYW1lZC1pY29uLXNwZWM=',
|
||||
width: 2048,
|
||||
height: 1152,
|
||||
sourceType: 'generated',
|
||||
prompt: '图标规范提示词',
|
||||
actualPrompt: '图标规范提示词',
|
||||
model: 'gpt-image-2',
|
||||
provider: 'VectorEngine',
|
||||
taskId: 'editor-icon-spec-renamed-1',
|
||||
},
|
||||
),
|
||||
);
|
||||
generateEditorIconSpritesheetMock.mockResolvedValueOnce({
|
||||
spritesheetImageSrc: 'data:image/png;base64,cmVuYW1lZC1zaGVldA==',
|
||||
@@ -3108,29 +3145,32 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
);
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '图标规范' }));
|
||||
const specDialog = screen.getByRole('dialog', { name: '生成规范' });
|
||||
fireEvent.change(within(specDialog).getByLabelText('玩法设定'), {
|
||||
target: { value: '背包整理玩法' },
|
||||
});
|
||||
setPlainTextEditorValue(
|
||||
within(specDialog).getByLabelText('玩法设定'),
|
||||
'背包整理玩法',
|
||||
);
|
||||
setPlainTextEditorValue(
|
||||
within(specDialog).getByLabelText('美术风格'),
|
||||
'清爽卡通',
|
||||
);
|
||||
fireEvent.click(
|
||||
within(specDialog).getByRole('button', { name: '提交生成规范' }),
|
||||
);
|
||||
|
||||
const generatedSpec = await screen.findByAltText(/画布图片:图标规范/u);
|
||||
expect(generateEditorImageMock).toHaveBeenCalledWith(
|
||||
expect(generateEditorIconSpecMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
kind: 'spec',
|
||||
prompt: expect.stringContaining('背包整理玩法'),
|
||||
playSetting: '背包整理玩法',
|
||||
artStyle: '清爽卡通',
|
||||
projectId: 'editor-project-default',
|
||||
assetFolderId: 'project',
|
||||
assetKind: 'icon-spec',
|
||||
generationInputs: expect.objectContaining({
|
||||
fields: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
title: '玩法设定',
|
||||
value: '背包整理玩法',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
generationInputs: {
|
||||
fields: [
|
||||
{ title: '玩法设定', value: '背包整理玩法' },
|
||||
{ title: '美术风格', value: '清爽卡通' },
|
||||
],
|
||||
references: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -3163,7 +3203,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
await waitFor(() => {
|
||||
expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
referenceImageSrc: 'resource-editor-icon-spec-renamed-1',
|
||||
referenceId: 'resource-editor-icon-spec-renamed-1',
|
||||
iconDescriptions: ['背包按钮'],
|
||||
model: 'gemini-3.1-flash-image-preview',
|
||||
aspectRatio: '1:1',
|
||||
@@ -3179,6 +3219,90 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
expect(screen.queryByAltText('画布图片:背包按钮')).toBeNull();
|
||||
});
|
||||
|
||||
it('uploads an authoritative icon spec before submitting icon spritesheet generation', async () => {
|
||||
uploadEditorMediaAssetFileMock.mockResolvedValueOnce({
|
||||
objectKey: 'generated-character-drafts/editor/icon-specs/uploaded.png',
|
||||
assetObjectId: 'asset-object-uploaded-icon-spec',
|
||||
legacyPublicPath:
|
||||
'/generated-character-drafts/editor/icon-specs/uploaded.png',
|
||||
src: 'https://oss.example.com/icon-specs/uploaded.png',
|
||||
});
|
||||
createEditorProjectResourceMock.mockImplementationOnce(
|
||||
async (projectId, input) => ({
|
||||
resourceId: 'resource-uploaded-icon-spec',
|
||||
projectId,
|
||||
imageSrc: input.imageSrc,
|
||||
objectKey: input.objectKey,
|
||||
assetObjectId: input.assetObjectId,
|
||||
width: input.width,
|
||||
height: input.height,
|
||||
sourceType: input.sourceType,
|
||||
assetKind: input.assetKind,
|
||||
generationInputs: input.generationInputs,
|
||||
}),
|
||||
);
|
||||
generateEditorIconSpritesheetMock.mockResolvedValueOnce({
|
||||
spritesheetImageSrc: 'data:image/png;base64,dXBsb2FkZWQtc2hlZXQ=',
|
||||
spritesheetWidth: 512,
|
||||
spritesheetHeight: 512,
|
||||
iconImageSrcs: [],
|
||||
prompt: '图标 prompt',
|
||||
actualPrompt: '图标 prompt',
|
||||
model: 'gemini-3.1-flash-image-preview',
|
||||
provider: 'VectorEngine',
|
||||
taskId: 'icon-task-uploaded-spec-1',
|
||||
});
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成图标素材' }));
|
||||
const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' });
|
||||
fireEvent.click(
|
||||
within(iconPanel).getByRole('button', { name: '图标规范' }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '上传图片' }));
|
||||
await userEvent.upload(
|
||||
screen.getByLabelText('上传媒体文件'),
|
||||
new File(['uploaded-icon-spec'], '上传图标规范.png', {
|
||||
type: 'image/png',
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
within(iconPanel).getByRole('button', { name: '上传图标规范.png' }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
|
||||
'editor-project-default',
|
||||
expect.objectContaining({
|
||||
objectKey: 'generated-character-drafts/editor/icon-specs/uploaded.png',
|
||||
assetKind: 'icon-spec',
|
||||
sourceType: 'uploaded',
|
||||
}),
|
||||
);
|
||||
|
||||
setPlainTextEditorValue(
|
||||
within(iconPanel).getByRole('textbox', { name: '素材描述' }),
|
||||
'上传后的返回按钮',
|
||||
);
|
||||
fireEvent.click(within(iconPanel).getByRole('button', { name: '生成' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
referenceId: 'resource-uploaded-icon-spec',
|
||||
iconDescriptions: ['上传后的返回按钮'],
|
||||
projectId: 'editor-project-default',
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(
|
||||
createEditorProjectResourceMock.mock.invocationCallOrder[0],
|
||||
).toBeLessThan(
|
||||
generateEditorIconSpritesheetMock.mock.invocationCallOrder[0]!,
|
||||
);
|
||||
});
|
||||
|
||||
it('exits character generation canvas picking with Escape', () => {
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
@@ -3312,7 +3436,6 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
expect(screen.getByText('角色')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '生成动画' })).toBeTruthy();
|
||||
expect(screen.queryByRole('dialog', { name: '重绘图片' })).toBeNull();
|
||||
expect(screen.queryByRole('dialog', { name: '改造图片' })).toBeNull();
|
||||
expect(
|
||||
screen.queryByRole('dialog', { name: '角色动画生成面板' }),
|
||||
).toBeNull();
|
||||
@@ -3323,7 +3446,6 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
expect(screen.getByRole('menuitem', { name: '生成动画' })).toBeTruthy();
|
||||
|
||||
expect(screen.queryByRole('dialog', { name: '重绘图片' })).toBeNull();
|
||||
expect(screen.queryByRole('dialog', { name: '改造图片' })).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成动画' }));
|
||||
expect(
|
||||
@@ -3598,8 +3720,11 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
assetFolderId: 'project',
|
||||
spritesheetLabel: '战斗UI设计图 素材图集',
|
||||
generationInputs: expect.objectContaining({
|
||||
version: 2,
|
||||
action: 'ui-design.extract-assets',
|
||||
fields: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 'prompt',
|
||||
title: '提取提示词',
|
||||
value:
|
||||
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。',
|
||||
@@ -3607,6 +3732,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
]),
|
||||
references: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 'source',
|
||||
title: 'UI设计图',
|
||||
label: '战斗UI设计图',
|
||||
}),
|
||||
|
||||
@@ -84,6 +84,153 @@ function buildPerfectPixelOperation(
|
||||
}
|
||||
|
||||
describe('ImageCanvasEditorModel', () => {
|
||||
it('hydrates complete V2 generation inputs without coercing values or requiring labels', () => {
|
||||
expect(
|
||||
generationInputsOrNull({
|
||||
version: 2,
|
||||
action: 'video.generate',
|
||||
fields: [
|
||||
{ id: 'durationSeconds', title: '时长', value: 8 },
|
||||
{ id: 'webSearchEnabled', title: '联网搜索', value: false },
|
||||
],
|
||||
references: [
|
||||
{
|
||||
id: 'reference',
|
||||
title: '参考视频',
|
||||
refType: 'asset',
|
||||
refId: 'asset-video-reference',
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
version: 2,
|
||||
action: 'video.generate',
|
||||
fields: [
|
||||
{ id: 'durationSeconds', title: '时长', value: 8 },
|
||||
{ id: 'webSearchEnabled', title: '联网搜索', value: false },
|
||||
],
|
||||
references: [
|
||||
{
|
||||
id: 'reference',
|
||||
title: '参考视频',
|
||||
refType: 'asset',
|
||||
refId: 'asset-video-reference',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores unknown nested V2 properties during non-strict resource hydration', () => {
|
||||
expect(
|
||||
generationInputsOrNull({
|
||||
version: 2,
|
||||
action: 'image.generate',
|
||||
futureTopLevelProperty: true,
|
||||
fields: [
|
||||
{
|
||||
id: 'prompt',
|
||||
title: '生成提示词',
|
||||
value: '未来配方',
|
||||
futureFieldProperty: 'ignored',
|
||||
},
|
||||
],
|
||||
references: [
|
||||
{
|
||||
id: 'reference',
|
||||
title: '参考图',
|
||||
label: '未来素材',
|
||||
refType: 'asset',
|
||||
refId: 'asset-future',
|
||||
futureReferenceProperty: 1,
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
version: 2,
|
||||
action: 'image.generate',
|
||||
fields: [{ id: 'prompt', title: '生成提示词', value: '未来配方' }],
|
||||
references: [
|
||||
{
|
||||
id: 'reference',
|
||||
title: '参考图',
|
||||
label: '未来素材',
|
||||
refType: 'asset',
|
||||
refId: 'asset-future',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('fails malformed V2 closed while preserving legacy hydration', () => {
|
||||
expect(
|
||||
generationInputsOrNull({
|
||||
version: 2,
|
||||
fields: [{ title: '生成提示词', value: '不得降级' }],
|
||||
references: [],
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
generationInputsOrNull({
|
||||
fields: [{ title: '生成提示词', value: '旧版配方' }],
|
||||
references: [],
|
||||
}),
|
||||
).toEqual({
|
||||
fields: [{ title: '生成提示词', value: '旧版配方' }],
|
||||
references: [],
|
||||
});
|
||||
expect(
|
||||
generationInputsOrNull({
|
||||
fields: [{ title: '生成提示词', value: '仅字段旧版配方' }],
|
||||
}),
|
||||
).toEqual({
|
||||
fields: [{ title: '生成提示词', value: '仅字段旧版配方' }],
|
||||
references: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves an invalid versioned resource marker instead of falling back to layout legacy inputs', () => {
|
||||
const hydrated = hydrateLayer(
|
||||
{
|
||||
layerId: 'layer-invalid-versioned-recipe',
|
||||
resourceId: 'resource-invalid-versioned-recipe',
|
||||
title: '损坏配方生成图',
|
||||
src: '/layout/fallback.png',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 320,
|
||||
height: 320,
|
||||
originalWidth: 320,
|
||||
originalHeight: 320,
|
||||
zIndex: 1,
|
||||
sourceType: 'generated',
|
||||
generationInputs: {
|
||||
fields: [{ title: '生成提示词', value: '不得当作旧版恢复' }],
|
||||
references: [],
|
||||
},
|
||||
},
|
||||
new Map([
|
||||
[
|
||||
'resource-invalid-versioned-recipe',
|
||||
{
|
||||
imageSrc: '/resource/generated.png',
|
||||
sourceType: 'generated',
|
||||
generationInputs: {
|
||||
version: 3,
|
||||
action: 'image.generate',
|
||||
fields: [],
|
||||
references: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
expect(hydrated).toMatchObject({
|
||||
generationInputs: null,
|
||||
generationInputsHydrationState: 'invalid-versioned',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the resource default kind separate from a layer override', () => {
|
||||
const layer = {
|
||||
id: 'layer-shared',
|
||||
@@ -415,6 +562,43 @@ describe('ImageCanvasEditorModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('restores V2 recipes from the persisted asset library after refresh', () => {
|
||||
const generationInputs = {
|
||||
version: 2 as const,
|
||||
action: 'video.generate' as const,
|
||||
fields: [
|
||||
{ id: 'durationSeconds', title: '时长', value: 8 },
|
||||
{ id: 'webSearchEnabled', title: '联网搜索', value: true },
|
||||
],
|
||||
references: [
|
||||
{
|
||||
id: 'reference',
|
||||
title: '参考视频',
|
||||
refType: 'asset' as const,
|
||||
refId: 'asset-reference',
|
||||
},
|
||||
],
|
||||
};
|
||||
const library = normalizeAssetLibrary({
|
||||
folders: [],
|
||||
assets: [
|
||||
{
|
||||
assetId: 'asset-v2',
|
||||
folderId: 'project',
|
||||
label: 'V2 视频',
|
||||
imageSrc: '/generated/video.mp4',
|
||||
width: 1280,
|
||||
height: 720,
|
||||
sourceType: 'generated',
|
||||
assetKind: 'video',
|
||||
generationInputs,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(library.assets[0]?.generationInputs).toEqual(generationInputs);
|
||||
});
|
||||
|
||||
it('round-trips an explicit character action into a movable sequence layer', () => {
|
||||
const frames = [
|
||||
{
|
||||
@@ -840,6 +1024,52 @@ describe('ImageCanvasEditorModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('restores V2 recipes from project resources after refresh', () => {
|
||||
const generationInputs = {
|
||||
version: 2 as const,
|
||||
action: 'video.generate' as const,
|
||||
fields: [
|
||||
{ id: 'durationSeconds', title: '时长', value: 8 },
|
||||
{ id: 'webSearchEnabled', title: '联网搜索', value: false },
|
||||
],
|
||||
references: [
|
||||
{
|
||||
id: 'reference',
|
||||
title: '参考视频',
|
||||
refType: 'project-resource' as const,
|
||||
refId: 'resource-reference',
|
||||
},
|
||||
],
|
||||
};
|
||||
const hydrated = hydrateLayer(
|
||||
{
|
||||
layerId: 'layer-v2',
|
||||
resourceId: 'resource-v2',
|
||||
title: 'V2 视频',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 1280,
|
||||
height: 720,
|
||||
originalWidth: 1280,
|
||||
originalHeight: 720,
|
||||
zIndex: 1,
|
||||
sourceType: 'generated',
|
||||
},
|
||||
new Map([
|
||||
[
|
||||
'resource-v2',
|
||||
{
|
||||
imageSrc: '/generated/video.mp4',
|
||||
assetKind: 'video',
|
||||
generationInputs,
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
expect(hydrated?.generationInputs).toEqual(generationInputs);
|
||||
});
|
||||
|
||||
it('hydrates a layer override ahead of the shared resource default', () => {
|
||||
const hydrated = hydrateLayer(
|
||||
{
|
||||
@@ -1419,6 +1649,34 @@ describe('ImageCanvasEditorModel', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps legacy perfect-pixel snapshots with empty string metadata recoverable', () => {
|
||||
const dialogId = 'dialog-perfect-pixel-empty-legacy-metadata';
|
||||
const operation = buildPerfectPixelOperation(dialogId);
|
||||
operation.request.generationInputs = {
|
||||
fields: [{ title: '', value: '' }],
|
||||
references: [
|
||||
{
|
||||
title: '',
|
||||
label: '',
|
||||
refType: 'asset',
|
||||
refId: '',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const hydrated = hydrateCanvasGenerationDialog({
|
||||
id: dialogId,
|
||||
mode: 'quick-edit',
|
||||
prompt: '完美像素',
|
||||
status: 'pending-confirmation',
|
||||
composerOpen: false,
|
||||
perfectPixelOperation: operation,
|
||||
});
|
||||
|
||||
expect(hydrated?.perfectPixelOperation).toEqual(operation);
|
||||
expect(hydrated).not.toHaveProperty('perfectPixelOperationInvalid');
|
||||
});
|
||||
|
||||
it('keeps a settled perfect-pixel placeholder valid without any local ledger', () => {
|
||||
// 中文注释:服务端完成 completion 后只做字段级改写,perfectPixelOperationId 会永久留在
|
||||
// 布局里;而账本在收口那一刻就被清掉了。这个组合是每一次**成功**完美像素的必然形状,
|
||||
@@ -1805,6 +2063,18 @@ describe('ImageCanvasEditorModel', () => {
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('migrates restored legacy ui specs to the icon spec flow', () => {
|
||||
expect(
|
||||
hydrateCanvasGenerationDialog({
|
||||
id: 'generation-dialog-legacy-ui-spec',
|
||||
mode: 'spec',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
specType: 'ui',
|
||||
})?.specType,
|
||||
).toBe('icon');
|
||||
});
|
||||
|
||||
it('drops restored generator references owned by another user', () => {
|
||||
const dialog: CanvasGenerationDialogState = {
|
||||
id: 'generation-dialog-owner',
|
||||
|
||||
@@ -27,7 +27,10 @@ import type {
|
||||
PerfectPixelOperationSnapshot,
|
||||
SnapCandidate,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { validateSoundEffectPrompt } from './ImageCanvasSoundEffectPromptModel';
|
||||
import {
|
||||
hydrateCanvasGenerationInputs,
|
||||
hydrateCanvasGenerationInputsResult,
|
||||
} from './ImageCanvasGenerationInputsModel';
|
||||
|
||||
export const EDITOR_ASSET_FOLDERS: EditorAssetFolder[] = [
|
||||
{
|
||||
@@ -221,6 +224,7 @@ export function createLayerFromAsset(
|
||||
assetKindOverride: null,
|
||||
assetKind: asset.assetKind ?? assetKind,
|
||||
generationInputs: asset.generationInputs,
|
||||
generationInputsHydrationState: asset.generationInputsHydrationState,
|
||||
imageSequenceFrames: asset.imageSequenceFrames,
|
||||
imageSequenceDurationMs: asset.imageSequenceDurationMs,
|
||||
} satisfies CanvasLayer;
|
||||
@@ -432,15 +436,6 @@ const PERFECT_PIXEL_PLACEHOLDER_KEYS = new Set([
|
||||
'originalWidth',
|
||||
'originalHeight',
|
||||
]);
|
||||
const PERFECT_PIXEL_GENERATION_INPUTS_KEYS = new Set(['fields', 'references']);
|
||||
const PERFECT_PIXEL_GENERATION_FIELD_KEYS = new Set(['title', 'value']);
|
||||
const PERFECT_PIXEL_GENERATION_REFERENCE_KEYS = new Set([
|
||||
'title',
|
||||
'label',
|
||||
'refType',
|
||||
'refId',
|
||||
]);
|
||||
|
||||
function isSnapshotRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
@@ -485,62 +480,6 @@ function isStableEditorMediaReference(value: unknown): value is string {
|
||||
}
|
||||
}
|
||||
|
||||
function hydratePerfectPixelGenerationInputs(
|
||||
value: unknown,
|
||||
): EditorAssetGenerationInputs | null {
|
||||
if (
|
||||
!isSnapshotRecord(value) ||
|
||||
!hasOnlySnapshotKeys(value, PERFECT_PIXEL_GENERATION_INPUTS_KEYS) ||
|
||||
!Array.isArray(value.fields) ||
|
||||
!Array.isArray(value.references)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const fields = value.fields.flatMap((field) => {
|
||||
if (
|
||||
!isSnapshotRecord(field) ||
|
||||
!hasOnlySnapshotKeys(field, PERFECT_PIXEL_GENERATION_FIELD_KEYS) ||
|
||||
typeof field.title !== 'string' ||
|
||||
typeof field.value !== 'string'
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return [{ title: field.title, value: field.value }];
|
||||
});
|
||||
const references: EditorAssetGenerationInputs['references'] =
|
||||
value.references.flatMap((reference) => {
|
||||
if (
|
||||
!isSnapshotRecord(reference) ||
|
||||
!hasOnlySnapshotKeys(
|
||||
reference,
|
||||
PERFECT_PIXEL_GENERATION_REFERENCE_KEYS,
|
||||
) ||
|
||||
typeof reference.title !== 'string' ||
|
||||
typeof reference.label !== 'string' ||
|
||||
(reference.refType !== 'project-resource' &&
|
||||
reference.refType !== 'asset') ||
|
||||
typeof reference.refId !== 'string'
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
title: reference.title,
|
||||
label: reference.label,
|
||||
refType: reference.refType as 'project-resource' | 'asset',
|
||||
refId: reference.refId,
|
||||
},
|
||||
];
|
||||
});
|
||||
if (
|
||||
fields.length !== value.fields.length ||
|
||||
references.length !== value.references.length
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { fields, references };
|
||||
}
|
||||
|
||||
/**
|
||||
* 中文注释:完美像素没有 durable job,恢复与人工重试只能依赖这份精确请求快照。
|
||||
* 因此这里按 v1 白名单重建,并交叉校验 dialog / operation / task / completion 身份;
|
||||
@@ -566,8 +505,7 @@ export function hydratePerfectPixelOperation(
|
||||
value.reconcileUntil < value.submittedAt ||
|
||||
value.reconcileUntil - value.submittedAt >
|
||||
LEGACY_PERFECT_PIXEL_RECONCILIATION_WINDOW_MS ||
|
||||
value.submittedAt >
|
||||
now + LEGACY_PERFECT_PIXEL_RECONCILIATION_WINDOW_MS
|
||||
value.submittedAt > now + LEGACY_PERFECT_PIXEL_RECONCILIATION_WINDOW_MS
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
@@ -613,9 +551,9 @@ export function hydratePerfectPixelOperation(
|
||||
} else if (request.generationInputs === null) {
|
||||
generationInputs = null;
|
||||
} else {
|
||||
generationInputs = hydratePerfectPixelGenerationInputs(
|
||||
request.generationInputs,
|
||||
);
|
||||
generationInputs = hydrateCanvasGenerationInputs(request.generationInputs, {
|
||||
strictWhitelist: true,
|
||||
});
|
||||
}
|
||||
if (
|
||||
request.generationInputs !== undefined &&
|
||||
@@ -679,8 +617,8 @@ function isPersistedReferenceResourceId(resourceId: string | null | undefined) {
|
||||
const normalizedResourceId = resourceId?.trim();
|
||||
return Boolean(
|
||||
normalizedResourceId &&
|
||||
!normalizedResourceId.startsWith('local-') &&
|
||||
!normalizedResourceId.startsWith('generation-dialog:'),
|
||||
!normalizedResourceId.startsWith('local-') &&
|
||||
!normalizedResourceId.startsWith('generation-dialog:'),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1257,9 +1195,7 @@ export function hydrateCanvasGenerationDialog(
|
||||
typeof snapshot.composerOpen === 'boolean' ? snapshot.composerOpen : true,
|
||||
sourceLayerId: stringOrUndefined(snapshot.sourceLayerId),
|
||||
generatedLayerId: stringOrUndefined(snapshot.generatedLayerId),
|
||||
specType: isSpecGenerationType(snapshot.specType)
|
||||
? snapshot.specType
|
||||
: undefined,
|
||||
specType: normalizeSpecGenerationType(snapshot.specType),
|
||||
specValues: hydrateSpecFormValues(snapshot.specValues),
|
||||
specReference: hydrateCharacterReference(
|
||||
snapshot.specReference,
|
||||
@@ -1437,12 +1373,12 @@ export function hydrateLayer(
|
||||
: canvasAssetKindOrNull(snapshot.assetKind);
|
||||
const assetKindOverrideFellBack = Boolean(
|
||||
resource &&
|
||||
hasPersistedAssetKindOverride &&
|
||||
persistedAssetKindOverride &&
|
||||
!isCanvasAssetKindOverrideCompatible(
|
||||
resourceAssetKind,
|
||||
persistedAssetKindOverride,
|
||||
),
|
||||
hasPersistedAssetKindOverride &&
|
||||
persistedAssetKindOverride &&
|
||||
!isCanvasAssetKindOverrideCompatible(
|
||||
resourceAssetKind,
|
||||
persistedAssetKindOverride,
|
||||
),
|
||||
);
|
||||
const assetKindOverride = assetKindOverrideFellBack
|
||||
? null
|
||||
@@ -1522,6 +1458,19 @@ export function hydrateLayer(
|
||||
imageSequenceDurationMs =
|
||||
audioDurationOrNull(snapshot.imageSequenceDurationMs) ?? undefined;
|
||||
}
|
||||
const resourceGenerationInputs = hydrateCanvasGenerationInputsResult(
|
||||
resource?.generationInputs,
|
||||
);
|
||||
const snapshotGenerationInputs = hydrateCanvasGenerationInputsResult(
|
||||
snapshot.generationInputs,
|
||||
);
|
||||
// 资源行是正式配方来源。它若明确是损坏/未来版本的 V2,不能因布局中的旧快照或
|
||||
// legacy fallback 被掩盖;只有资源值真正 absent 时才读取布局快照。
|
||||
const generationInputsHydration = isFormalCharacterAnimation
|
||||
? resourceGenerationInputs
|
||||
: resourceGenerationInputs.state === 'absent'
|
||||
? snapshotGenerationInputs
|
||||
: resourceGenerationInputs;
|
||||
return {
|
||||
id: layerId,
|
||||
resourceId,
|
||||
@@ -1592,10 +1541,8 @@ export function hydrateLayer(
|
||||
resourceAssetKind,
|
||||
assetKindOverride,
|
||||
assetKind,
|
||||
generationInputs: isFormalCharacterAnimation
|
||||
? generationInputsOrNull(resource?.generationInputs)
|
||||
: (generationInputsOrNull(resource?.generationInputs) ??
|
||||
generationInputsOrNull(snapshot.generationInputs)),
|
||||
generationInputs: generationInputsHydration.inputs,
|
||||
generationInputsHydrationState: generationInputsHydration.state,
|
||||
hidden: booleanFromSnapshot(snapshot.hidden),
|
||||
locked: booleanFromSnapshot(snapshot.locked),
|
||||
flipX: booleanFromSnapshot(snapshot.flipX),
|
||||
@@ -1812,6 +1759,9 @@ export function mapAssetLibrarySnapshot(library: EditorAssetLibrarySnapshot): {
|
||||
showcaseLikeCount: asset.showcaseLikeCount ?? null,
|
||||
assetKind,
|
||||
generationInputs: generationInputsOrNull(asset.generationInputs),
|
||||
generationInputsHydrationState: hydrateCanvasGenerationInputsResult(
|
||||
asset.generationInputs,
|
||||
).state,
|
||||
imageSequenceFrames: asset.imageSequenceFrames ?? undefined,
|
||||
imageSequenceDurationMs: asset.imageSequenceDurationMs ?? undefined,
|
||||
};
|
||||
@@ -1930,7 +1880,10 @@ export type CanvasAssetKindOverrideFallbackIssue = {
|
||||
};
|
||||
|
||||
type CanvasAssetMediaFamily =
|
||||
'image' | 'character-animation' | 'video' | 'audio';
|
||||
| 'image'
|
||||
| 'character-animation'
|
||||
| 'video'
|
||||
| 'audio';
|
||||
|
||||
function resolveCanvasAssetMediaFamily(
|
||||
assetKind: CanvasAssetKind | null | undefined,
|
||||
@@ -2180,8 +2133,8 @@ export function isLayerLinkedToAsset(layer: CanvasLayer, asset: EditorAsset) {
|
||||
layer.sourceAssetId === asset.id ||
|
||||
Boolean(
|
||||
assetSourceResourceId &&
|
||||
(layer.resourceId === assetSourceResourceId ||
|
||||
layer.sourceResourceId === assetSourceResourceId),
|
||||
(layer.resourceId === assetSourceResourceId ||
|
||||
layer.sourceResourceId === assetSourceResourceId),
|
||||
) ||
|
||||
Boolean(
|
||||
asset.assetObjectId && layer.assetObjectId === asset.assetObjectId,
|
||||
@@ -2194,124 +2147,7 @@ export function isLayerLinkedToAsset(layer: CanvasLayer, asset: EditorAsset) {
|
||||
export function generationInputsOrNull(
|
||||
value: unknown,
|
||||
): CanvasGenerationInputs | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
const snapshot = value as {
|
||||
fields?: unknown;
|
||||
references?: unknown;
|
||||
soundEffect?: unknown;
|
||||
};
|
||||
const fields = Array.isArray(snapshot.fields)
|
||||
? snapshot.fields.flatMap((field) => {
|
||||
if (!field || typeof field !== 'object') {
|
||||
return [];
|
||||
}
|
||||
const item = field as { title?: unknown; value?: unknown };
|
||||
const title = stringOrNull(item.title);
|
||||
const fieldValue = stringOrNull(item.value);
|
||||
return title && fieldValue ? [{ title, value: fieldValue }] : [];
|
||||
})
|
||||
: [];
|
||||
const references = Array.isArray(snapshot.references)
|
||||
? snapshot.references.flatMap((reference) => {
|
||||
if (!reference || typeof reference !== 'object') {
|
||||
return [];
|
||||
}
|
||||
const item = reference as {
|
||||
title?: unknown;
|
||||
label?: unknown;
|
||||
refType?: unknown;
|
||||
refId?: unknown;
|
||||
};
|
||||
const title = stringOrNull(item.title);
|
||||
const label = stringOrNull(item.label);
|
||||
const refType: 'project-resource' | 'asset' | null =
|
||||
item.refType === 'project-resource' || item.refType === 'asset'
|
||||
? item.refType
|
||||
: null;
|
||||
const refId = stringOrNull(item.refId);
|
||||
return title && label && refType && refId
|
||||
? [{ title, label, refType, refId }]
|
||||
: [];
|
||||
})
|
||||
: [];
|
||||
|
||||
const hasSoundEffect = Object.prototype.hasOwnProperty.call(
|
||||
snapshot,
|
||||
'soundEffect',
|
||||
);
|
||||
const soundEffect = hasSoundEffect
|
||||
? soundEffectGenerationMetadataOrNull(snapshot.soundEffect)
|
||||
: null;
|
||||
if (hasSoundEffect && !soundEffect) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return fields.length || references.length || soundEffect
|
||||
? {
|
||||
fields,
|
||||
references,
|
||||
...(soundEffect ? { soundEffect } : {}),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
function soundEffectGenerationMetadataOrNull(
|
||||
value: unknown,
|
||||
): NonNullable<CanvasGenerationInputs['soundEffect']> | null {
|
||||
if (!isSnapshotRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
const userPrompt =
|
||||
typeof value.userPrompt === 'string' ? value.userPrompt : '';
|
||||
const actualPrompt =
|
||||
typeof value.actualPrompt === 'string' ? value.actualPrompt : '';
|
||||
const userPromptValidation = validateSoundEffectPrompt(userPrompt);
|
||||
const actualPromptValidation = validateSoundEffectPrompt(actualPrompt);
|
||||
if (
|
||||
value.schemaVersion !== 2 ||
|
||||
value.model !== EDITOR_SOUND_EFFECT_MODEL ||
|
||||
(value.durationMode !== 'auto' && value.durationMode !== 'manual') ||
|
||||
typeof value.loop !== 'boolean' ||
|
||||
!userPromptValidation.ok ||
|
||||
userPromptValidation.prompt !== userPrompt ||
|
||||
!actualPromptValidation.ok ||
|
||||
actualPromptValidation.prompt !== actualPrompt ||
|
||||
typeof value.actualDurationSeconds !== 'number' ||
|
||||
!Number.isFinite(value.actualDurationSeconds) ||
|
||||
value.actualDurationSeconds <= 0 ||
|
||||
value.actualDurationSeconds > 600
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
let requestedDurationSeconds: number | null;
|
||||
if (value.durationMode === 'auto') {
|
||||
if (value.requestedDurationSeconds !== null) {
|
||||
return null;
|
||||
}
|
||||
requestedDurationSeconds = null;
|
||||
} else {
|
||||
if (
|
||||
typeof value.requestedDurationSeconds !== 'number' ||
|
||||
!Number.isFinite(value.requestedDurationSeconds) ||
|
||||
value.requestedDurationSeconds < SOUND_EFFECT_DURATION_MIN_SECONDS ||
|
||||
value.requestedDurationSeconds > SOUND_EFFECT_DURATION_MAX_SECONDS
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
requestedDurationSeconds = value.requestedDurationSeconds;
|
||||
}
|
||||
return {
|
||||
schemaVersion: 2,
|
||||
userPrompt,
|
||||
actualPrompt,
|
||||
model: EDITOR_SOUND_EFFECT_MODEL,
|
||||
durationMode: value.durationMode,
|
||||
requestedDurationSeconds,
|
||||
actualDurationSeconds: value.actualDurationSeconds,
|
||||
loop: value.loop,
|
||||
};
|
||||
return hydrateCanvasGenerationInputs(value);
|
||||
}
|
||||
|
||||
export function canvasAssetKindOrNull(value: unknown): CanvasAssetKind | null {
|
||||
@@ -2379,12 +2215,16 @@ function isGenerationStatus(
|
||||
function isSpecGenerationType(
|
||||
value: unknown,
|
||||
): value is NonNullable<CanvasGenerationDialogState['specType']> {
|
||||
return (
|
||||
value === 'character' ||
|
||||
value === 'ui' ||
|
||||
value === 'icon' ||
|
||||
value === 'custom'
|
||||
);
|
||||
return value === 'character' || value === 'icon' || value === 'custom';
|
||||
}
|
||||
|
||||
function normalizeSpecGenerationType(
|
||||
value: unknown,
|
||||
): CanvasGenerationDialogState['specType'] {
|
||||
if (value === 'ui') {
|
||||
return 'icon';
|
||||
}
|
||||
return isSpecGenerationType(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function hydratePublicationWorkflowId(
|
||||
|
||||
@@ -67,6 +67,7 @@ export type EditorAsset = {
|
||||
showcaseSubmitError?: string | null;
|
||||
assetKind?: CanvasAssetKind | null;
|
||||
generationInputs?: CanvasGenerationInputs | null;
|
||||
generationInputsHydrationState?: CanvasGenerationInputsHydrationState;
|
||||
imageSequenceFrames?: EditorImageSequenceFrameResult[];
|
||||
imageSequenceDurationMs?: number;
|
||||
uploadStatus?: 'uploading' | 'failed';
|
||||
@@ -74,24 +75,56 @@ export type EditorAsset = {
|
||||
uploadMessage?: string;
|
||||
};
|
||||
|
||||
export type CanvasGenerationAction =
|
||||
| 'image.generate'
|
||||
| 'scene.generate'
|
||||
| 'spec.generate'
|
||||
| 'character.generate'
|
||||
| 'icon.generate'
|
||||
| 'ui-design.generate'
|
||||
| 'publication.generate'
|
||||
| 'video.generate'
|
||||
| 'audio.sound-effect.generate'
|
||||
| 'audio.background-music.generate'
|
||||
| 'character-animation.generate'
|
||||
| 'image.edit'
|
||||
| 'ui-design.extract-assets'
|
||||
| 'image.perfect-pixel'
|
||||
| 'spritesheet.split'
|
||||
| 'image.remove-background'
|
||||
| 'image.crop-expand';
|
||||
|
||||
export type CanvasGenerationInputValue = string | number | boolean;
|
||||
|
||||
export type CanvasGenerationInputField = {
|
||||
id?: string;
|
||||
title: string;
|
||||
value: string;
|
||||
value: CanvasGenerationInputValue;
|
||||
};
|
||||
|
||||
export type CanvasGenerationInputReference = {
|
||||
id?: string;
|
||||
title: string;
|
||||
label: string;
|
||||
label?: string;
|
||||
refType: 'project-resource' | 'asset';
|
||||
refId: string;
|
||||
};
|
||||
|
||||
export type CanvasGenerationInputs = {
|
||||
version?: 2;
|
||||
action?: CanvasGenerationAction;
|
||||
fields: CanvasGenerationInputField[];
|
||||
references: CanvasGenerationInputReference[];
|
||||
soundEffect?: EditorSoundEffectGenerationMetadataV2;
|
||||
};
|
||||
|
||||
// hydrate 后的客户端运行时来源状态;不属于 generationInputs 持久化契约。
|
||||
// 用于区分真正缺少配方的 legacy 数据与损坏/未来版本的版本化配方。
|
||||
export type CanvasGenerationInputsHydrationState =
|
||||
| 'absent'
|
||||
| 'valid'
|
||||
| 'invalid-versioned';
|
||||
|
||||
export type CanvasLayer = {
|
||||
id: string;
|
||||
resourceId: string;
|
||||
@@ -124,6 +157,7 @@ export type CanvasLayer = {
|
||||
assetKindOverride?: CanvasAssetKind | null;
|
||||
assetKind?: CanvasAssetKind | null;
|
||||
generationInputs?: CanvasGenerationInputs | null;
|
||||
generationInputsHydrationState?: CanvasGenerationInputsHydrationState;
|
||||
imageSequenceFrames?: EditorImageSequenceFrameResult[];
|
||||
imageSequenceDurationMs?: number;
|
||||
previewVideoPath?: string | null;
|
||||
@@ -163,7 +197,7 @@ export type EditorAssetFolder = {
|
||||
persisted: boolean;
|
||||
};
|
||||
|
||||
export type SpecGenerationType = 'character' | 'ui' | 'icon' | 'custom';
|
||||
export type SpecGenerationType = 'character' | 'icon' | 'custom';
|
||||
|
||||
export type SpecFormValues = {
|
||||
playSetting: string;
|
||||
@@ -228,6 +262,7 @@ export type GenerateDialogState = {
|
||||
assetLabel?: string;
|
||||
status: 'idle' | 'generating' | 'pending-confirmation' | 'failed';
|
||||
composerOpen?: boolean;
|
||||
isRedrawAsNewArtifact?: boolean;
|
||||
sourceLayerId?: string;
|
||||
generatedLayerId?: string;
|
||||
specType?: SpecGenerationType;
|
||||
|
||||
@@ -1407,7 +1407,9 @@ describe('ImageCanvasEditorView', () => {
|
||||
expect(
|
||||
within(infoPanel).queryByRole('button', { name: '复制Prompt' }),
|
||||
).toBeNull();
|
||||
expect(screen.getByRole('button', { name: '改造' })).toBeTruthy();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: '改造' }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('hydrates canvas images from Resolution instead of saved Size', async () => {
|
||||
@@ -1925,7 +1927,9 @@ describe('ImageCanvasEditorView', () => {
|
||||
expect(
|
||||
screen.getByRole('button', { name: '查看大鱼素材图片信息' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '改造' })).toBeTruthy();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: '改造' }),
|
||||
).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开素材' }));
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ import type {
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { getEditorUploadAccept, isImageFile } from './ImageCanvasFileModel';
|
||||
import { createLayerGenerationDialogDraft } from './ImageCanvasGenerationDialogModel';
|
||||
import { hydrateCanvasGenerationInputsResult } from './ImageCanvasGenerationInputsModel';
|
||||
import {
|
||||
applyEditorGenerationPricingConfig,
|
||||
isCanvasGenerationDialog,
|
||||
@@ -183,7 +184,7 @@ function openCanvasStartupTool(
|
||||
return;
|
||||
}
|
||||
if (tool === 'icon-spec') {
|
||||
generationSurface.openSpecDialog('ui');
|
||||
generationSurface.openSpecDialog('icon');
|
||||
return;
|
||||
}
|
||||
if (tool === 'ui-design') {
|
||||
@@ -313,6 +314,7 @@ function createAssetActionLayer(asset: EditorAsset): CanvasLayer {
|
||||
sourceAssetId: asset.id,
|
||||
assetKind: asset.assetKind ?? null,
|
||||
generationInputs: asset.generationInputs ?? null,
|
||||
generationInputsHydrationState: asset.generationInputsHydrationState,
|
||||
imageSequenceFrames: asset.imageSequenceFrames,
|
||||
imageSequenceDurationMs: asset.imageSequenceDurationMs,
|
||||
};
|
||||
@@ -1092,6 +1094,9 @@ export function ImageCanvasEditorView({
|
||||
asset.imageSequenceDurationMs ?? undefined,
|
||||
assetKind: persistedAssetKind,
|
||||
generationInputs: generationInputsOrNull(asset.generationInputs),
|
||||
generationInputsHydrationState:
|
||||
hydrateCanvasGenerationInputsResult(asset.generationInputs)
|
||||
.state,
|
||||
},
|
||||
]);
|
||||
setAssetFolders((currentFolders) =>
|
||||
@@ -1164,6 +1169,8 @@ export function ImageCanvasEditorView({
|
||||
imageSequenceDurationMs: asset.imageSequenceDurationMs ?? undefined,
|
||||
assetKind: canvasAssetKindOrNull(asset.assetKind),
|
||||
generationInputs: generationInputsOrNull(asset.generationInputs),
|
||||
generationInputsHydrationState:
|
||||
hydrateCanvasGenerationInputsResult(asset.generationInputs).state,
|
||||
},
|
||||
]);
|
||||
setAssetFolders((currentFolders) =>
|
||||
@@ -1840,6 +1847,12 @@ export function ImageCanvasEditorView({
|
||||
});
|
||||
const openLayerGenerationDialog = useCallback(
|
||||
(layer: CanvasLayer) => {
|
||||
if (layer.generationInputsHydrationState === 'invalid-versioned') {
|
||||
showGenerationWarning(
|
||||
'保存的生成参数版本不受支持或数据损坏,无法改造。',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const existingDialog = [...canvasGenerationDialogsRef.current]
|
||||
.reverse()
|
||||
.find((dialog) => dialog.generatedLayerId === layer.id);
|
||||
@@ -1857,6 +1870,7 @@ export function ImageCanvasEditorView({
|
||||
layersRef.current,
|
||||
layer,
|
||||
),
|
||||
availableLayers: layersRef.current,
|
||||
});
|
||||
if (!restoredDraft) {
|
||||
return false;
|
||||
@@ -1872,10 +1886,14 @@ export function ImageCanvasEditorView({
|
||||
openCanvasGenerationDialog,
|
||||
setSelectedLayerId,
|
||||
setSelectedLayerIds,
|
||||
showGenerationWarning,
|
||||
],
|
||||
);
|
||||
const openGeneratedLayerGenerationDialog = useCallback(
|
||||
(layer: CanvasLayer) => {
|
||||
// TODO(产品确认): 已完成的生成结果被普通点击时,是否仍应恢复并显示其生成浮层?
|
||||
// 当前实现会这样做,但现有文档只定义点击图层后的选中/工具栏行为,以及用户显式
|
||||
// 点击“改造”时的配方恢复,未定义这一隐式恢复入口。确认前保留既有行为。
|
||||
if (layer.sourceType !== 'generated') {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -205,6 +205,29 @@ describe('ImageCanvasExportModel', () => {
|
||||
expect(metadata.visible.type).toBe('游戏场景');
|
||||
});
|
||||
|
||||
it('uses the reference fallback label when export metadata is blank', () => {
|
||||
const metadata = buildLayerExportMetadata(
|
||||
buildLayer({
|
||||
generationInputs: {
|
||||
fields: [],
|
||||
references: [
|
||||
{
|
||||
title: '参考图',
|
||||
label: ' ',
|
||||
refType: 'asset',
|
||||
refId: 'asset-reference',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
'images/001-layer.png',
|
||||
);
|
||||
|
||||
expect(metadata.visible.generationInputs?.references[0]?.label).toBe(
|
||||
'参考素材',
|
||||
);
|
||||
});
|
||||
|
||||
it('filters built-in prompts from exported visible generation inputs', () => {
|
||||
const metadata = buildLayerExportMetadata(
|
||||
buildLayer({
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
import {
|
||||
DEFAULT_ICON_DESCRIPTIONS,
|
||||
formatEditorGenerationInputFieldValue,
|
||||
formatGenerationInputValue,
|
||||
formatLayerImageType,
|
||||
getEditorLayerModelDisplayName,
|
||||
isEditorUserVisibleGenerationInputField,
|
||||
@@ -537,7 +538,7 @@ const BUILT_IN_GENERATION_INPUT_VALUES_BY_TITLE = new Map(
|
||||
|
||||
function isBuiltInGenerationInputField(field: CanvasGenerationInputField) {
|
||||
const normalizedTitle = field.title.trim();
|
||||
const normalizedValue = field.value.trim();
|
||||
const normalizedValue = formatGenerationInputValue(field.value).trim();
|
||||
const builtInValues =
|
||||
BUILT_IN_GENERATION_INPUT_VALUES_BY_TITLE.get(normalizedTitle);
|
||||
return (
|
||||
@@ -559,7 +560,7 @@ function buildVisibleGenerationInputs(layer: CanvasLayer) {
|
||||
const references =
|
||||
layer.generationInputs?.references.map((reference) => ({
|
||||
title: reference.title,
|
||||
label: reference.label,
|
||||
label: reference.label?.trim() || '参考素材',
|
||||
refType: reference.refType,
|
||||
refId: reference.refId,
|
||||
})) ?? [];
|
||||
|
||||
@@ -1520,7 +1520,7 @@ export function ImageCanvasGenerationComposerView({
|
||||
onFocus={onSpecMenuPointerEnter}
|
||||
onBlur={onSpecMenuPointerLeave}
|
||||
>
|
||||
{(['character', 'ui', 'custom'] as const).map((specType) => (
|
||||
{(['character', 'icon', 'custom'] as const).map((specType) => (
|
||||
<PlatformFloatingMenuItem
|
||||
key={specType}
|
||||
className="image-canvas-editor__spec-menu-item"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,382 @@
|
||||
import {
|
||||
EDITOR_SOUND_EFFECT_MODEL,
|
||||
SOUND_EFFECT_DURATION_MAX_SECONDS,
|
||||
SOUND_EFFECT_DURATION_MIN_SECONDS,
|
||||
} from '../../../packages/shared/src/contracts/editorAudio';
|
||||
import type {
|
||||
CanvasGenerationAction,
|
||||
CanvasGenerationInputField,
|
||||
CanvasGenerationInputReference,
|
||||
CanvasGenerationInputs,
|
||||
CanvasGenerationInputsHydrationState,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { validateSoundEffectPrompt } from './ImageCanvasSoundEffectPromptModel';
|
||||
|
||||
export const CANVAS_GENERATION_ACTIONS = [
|
||||
'image.generate',
|
||||
'scene.generate',
|
||||
'spec.generate',
|
||||
'character.generate',
|
||||
'icon.generate',
|
||||
'ui-design.generate',
|
||||
'publication.generate',
|
||||
'video.generate',
|
||||
'audio.sound-effect.generate',
|
||||
'audio.background-music.generate',
|
||||
'character-animation.generate',
|
||||
'image.edit',
|
||||
'ui-design.extract-assets',
|
||||
'image.perfect-pixel',
|
||||
'spritesheet.split',
|
||||
'image.remove-background',
|
||||
'image.crop-expand',
|
||||
] as const satisfies readonly CanvasGenerationAction[];
|
||||
|
||||
const REMIXABLE_CANVAS_GENERATION_ACTIONS = new Set<CanvasGenerationAction>([
|
||||
'image.generate',
|
||||
'scene.generate',
|
||||
'spec.generate',
|
||||
'character.generate',
|
||||
'icon.generate',
|
||||
'ui-design.generate',
|
||||
'publication.generate',
|
||||
'video.generate',
|
||||
'audio.sound-effect.generate',
|
||||
'audio.background-music.generate',
|
||||
'character-animation.generate',
|
||||
'ui-design.extract-assets',
|
||||
]);
|
||||
|
||||
// 中文注释:image.edit 是图片快速编辑的原位替换记录,结果层就是被覆盖后的原层;
|
||||
// 其 source 指向替换前的编辑目标,不能再作为“改造”新产物可靠恢复。继续修改应重新
|
||||
// 使用“快速编辑”,所以 image.edit 只属于已知 action,不属于可改造 action。
|
||||
|
||||
const CANVAS_GENERATION_ACTION_SET = new Set<string>(CANVAS_GENERATION_ACTIONS);
|
||||
const V2_GENERATION_INPUT_KEYS = new Set([
|
||||
'version',
|
||||
'action',
|
||||
'fields',
|
||||
'references',
|
||||
'soundEffect',
|
||||
]);
|
||||
const V2_GENERATION_FIELD_KEYS = new Set(['id', 'title', 'value']);
|
||||
const V2_GENERATION_REFERENCE_KEYS = new Set([
|
||||
'id',
|
||||
'title',
|
||||
'label',
|
||||
'refType',
|
||||
'refId',
|
||||
]);
|
||||
const LEGACY_GENERATION_INPUT_KEYS = new Set([
|
||||
'fields',
|
||||
'references',
|
||||
'soundEffect',
|
||||
]);
|
||||
const LEGACY_GENERATION_FIELD_KEYS = new Set(['title', 'value']);
|
||||
const LEGACY_GENERATION_REFERENCE_KEYS = new Set([
|
||||
'title',
|
||||
'label',
|
||||
'refType',
|
||||
'refId',
|
||||
]);
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function hasOnlyKeys(
|
||||
value: Record<string, unknown>,
|
||||
allowedKeys: ReadonlySet<string>,
|
||||
) {
|
||||
return Object.keys(value).every((key) => allowedKeys.has(key));
|
||||
}
|
||||
|
||||
function isPresentString(value: unknown): value is string {
|
||||
return typeof value === 'string' && Boolean(value.trim());
|
||||
}
|
||||
|
||||
function isGenerationInputValue(
|
||||
value: unknown,
|
||||
): value is CanvasGenerationInputField['value'] {
|
||||
return (
|
||||
typeof value === 'string' ||
|
||||
(typeof value === 'number' && Number.isFinite(value)) ||
|
||||
typeof value === 'boolean'
|
||||
);
|
||||
}
|
||||
|
||||
function soundEffectGenerationMetadataOrNull(
|
||||
value: unknown,
|
||||
): NonNullable<CanvasGenerationInputs['soundEffect']> | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
const userPrompt =
|
||||
typeof value.userPrompt === 'string' ? value.userPrompt : '';
|
||||
const actualPrompt =
|
||||
typeof value.actualPrompt === 'string' ? value.actualPrompt : '';
|
||||
const userPromptValidation = validateSoundEffectPrompt(userPrompt);
|
||||
const actualPromptValidation = validateSoundEffectPrompt(actualPrompt);
|
||||
if (
|
||||
value.schemaVersion !== 2 ||
|
||||
value.model !== EDITOR_SOUND_EFFECT_MODEL ||
|
||||
(value.durationMode !== 'auto' && value.durationMode !== 'manual') ||
|
||||
typeof value.loop !== 'boolean' ||
|
||||
!userPromptValidation.ok ||
|
||||
userPromptValidation.prompt !== userPrompt ||
|
||||
!actualPromptValidation.ok ||
|
||||
actualPromptValidation.prompt !== actualPrompt ||
|
||||
typeof value.actualDurationSeconds !== 'number' ||
|
||||
!Number.isFinite(value.actualDurationSeconds) ||
|
||||
value.actualDurationSeconds <= 0 ||
|
||||
value.actualDurationSeconds > 600
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
let requestedDurationSeconds: number | null;
|
||||
if (value.durationMode === 'auto') {
|
||||
if (value.requestedDurationSeconds !== null) {
|
||||
return null;
|
||||
}
|
||||
requestedDurationSeconds = null;
|
||||
} else {
|
||||
if (
|
||||
typeof value.requestedDurationSeconds !== 'number' ||
|
||||
!Number.isFinite(value.requestedDurationSeconds) ||
|
||||
value.requestedDurationSeconds < SOUND_EFFECT_DURATION_MIN_SECONDS ||
|
||||
value.requestedDurationSeconds > SOUND_EFFECT_DURATION_MAX_SECONDS
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
requestedDurationSeconds = value.requestedDurationSeconds;
|
||||
}
|
||||
return {
|
||||
schemaVersion: 2,
|
||||
userPrompt,
|
||||
actualPrompt,
|
||||
model: EDITOR_SOUND_EFFECT_MODEL,
|
||||
durationMode: value.durationMode,
|
||||
requestedDurationSeconds,
|
||||
actualDurationSeconds: value.actualDurationSeconds,
|
||||
loop: value.loop,
|
||||
};
|
||||
}
|
||||
|
||||
export function isCanvasGenerationAction(
|
||||
value: unknown,
|
||||
): value is CanvasGenerationAction {
|
||||
return typeof value === 'string' && CANVAS_GENERATION_ACTION_SET.has(value);
|
||||
}
|
||||
|
||||
function hasNormalizedCanvasGenerationInputsStructure(
|
||||
value: unknown,
|
||||
strictWhitelist: boolean,
|
||||
): value is CanvasGenerationInputs & {
|
||||
version: 2;
|
||||
action: CanvasGenerationAction;
|
||||
} {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
value.version !== 2 ||
|
||||
!isCanvasGenerationAction(value.action) ||
|
||||
!Array.isArray(value.fields) ||
|
||||
!Array.isArray(value.references)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
(!Object.prototype.hasOwnProperty.call(value, 'soundEffect') ||
|
||||
soundEffectGenerationMetadataOrNull(value.soundEffect) !== null) &&
|
||||
value.fields.every(
|
||||
(field) =>
|
||||
isRecord(field) &&
|
||||
(!strictWhitelist || hasOnlyKeys(field, V2_GENERATION_FIELD_KEYS)) &&
|
||||
isPresentString(field.id) &&
|
||||
typeof field.title === 'string' &&
|
||||
isGenerationInputValue(field.value),
|
||||
) &&
|
||||
value.references.every(
|
||||
(reference) =>
|
||||
isRecord(reference) &&
|
||||
(!strictWhitelist ||
|
||||
hasOnlyKeys(reference, V2_GENERATION_REFERENCE_KEYS)) &&
|
||||
isPresentString(reference.id) &&
|
||||
typeof reference.title === 'string' &&
|
||||
(reference.label === undefined ||
|
||||
typeof reference.label === 'string') &&
|
||||
(reference.refType === 'project-resource' ||
|
||||
reference.refType === 'asset') &&
|
||||
isPresentString(reference.refId),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function isNormalizedCanvasGenerationInputsStructure(
|
||||
value: unknown,
|
||||
): value is CanvasGenerationInputs & {
|
||||
version: 2;
|
||||
action: CanvasGenerationAction;
|
||||
} {
|
||||
return hasNormalizedCanvasGenerationInputsStructure(value, true);
|
||||
}
|
||||
|
||||
export function isRemixableCanvasGenerationAction(
|
||||
action: CanvasGenerationAction,
|
||||
) {
|
||||
return REMIXABLE_CANVAS_GENERATION_ACTIONS.has(action);
|
||||
}
|
||||
|
||||
function cloneV2GenerationInputs(
|
||||
value: Record<string, unknown>,
|
||||
strictWhitelist: boolean,
|
||||
): CanvasGenerationInputs | null {
|
||||
if (
|
||||
(strictWhitelist && !hasOnlyKeys(value, V2_GENERATION_INPUT_KEYS)) ||
|
||||
!hasNormalizedCanvasGenerationInputsStructure(value, strictWhitelist)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const hasSoundEffect = Object.prototype.hasOwnProperty.call(
|
||||
value,
|
||||
'soundEffect',
|
||||
);
|
||||
const soundEffect = hasSoundEffect
|
||||
? soundEffectGenerationMetadataOrNull(value.soundEffect)
|
||||
: null;
|
||||
if (hasSoundEffect && !soundEffect) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
version: 2,
|
||||
action: value.action,
|
||||
fields: value.fields.map((field) => ({
|
||||
id: field.id,
|
||||
title: field.title,
|
||||
value: field.value,
|
||||
})),
|
||||
references: value.references.map((reference) => ({
|
||||
id: reference.id,
|
||||
title: reference.title,
|
||||
...(reference.label === undefined ? {} : { label: reference.label }),
|
||||
refType: reference.refType,
|
||||
refId: reference.refId,
|
||||
})),
|
||||
...(soundEffect ? { soundEffect } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function hydrateLegacyGenerationInputs(
|
||||
value: Record<string, unknown>,
|
||||
strictWhitelist: boolean,
|
||||
): CanvasGenerationInputs | null {
|
||||
if (
|
||||
strictWhitelist &&
|
||||
(!hasOnlyKeys(value, LEGACY_GENERATION_INPUT_KEYS) ||
|
||||
!Array.isArray(value.fields) ||
|
||||
!Array.isArray(value.references))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const rawFields = Array.isArray(value.fields) ? value.fields : [];
|
||||
const rawReferences = Array.isArray(value.references) ? value.references : [];
|
||||
const hasSoundEffect = Object.prototype.hasOwnProperty.call(
|
||||
value,
|
||||
'soundEffect',
|
||||
);
|
||||
const soundEffect = hasSoundEffect
|
||||
? soundEffectGenerationMetadataOrNull(value.soundEffect)
|
||||
: null;
|
||||
if (hasSoundEffect && !soundEffect) {
|
||||
return null;
|
||||
}
|
||||
const fields = rawFields.flatMap((field) => {
|
||||
if (
|
||||
!isRecord(field) ||
|
||||
(strictWhitelist && !hasOnlyKeys(field, LEGACY_GENERATION_FIELD_KEYS)) ||
|
||||
(strictWhitelist
|
||||
? typeof field.title !== 'string'
|
||||
: !isPresentString(field.title)) ||
|
||||
(strictWhitelist
|
||||
? typeof field.value !== 'string'
|
||||
: !isPresentString(field.value))
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return [{ title: field.title as string, value: field.value as string }];
|
||||
});
|
||||
const references = rawReferences.flatMap((reference) => {
|
||||
if (
|
||||
!isRecord(reference) ||
|
||||
(strictWhitelist &&
|
||||
!hasOnlyKeys(reference, LEGACY_GENERATION_REFERENCE_KEYS)) ||
|
||||
(strictWhitelist
|
||||
? typeof reference.title !== 'string'
|
||||
: !isPresentString(reference.title)) ||
|
||||
(strictWhitelist
|
||||
? typeof reference.label !== 'string'
|
||||
: !isPresentString(reference.label)) ||
|
||||
(reference.refType !== 'project-resource' &&
|
||||
reference.refType !== 'asset') ||
|
||||
(strictWhitelist
|
||||
? typeof reference.refId !== 'string'
|
||||
: !isPresentString(reference.refId))
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
title: reference.title as string,
|
||||
label: reference.label as string,
|
||||
refType: reference.refType,
|
||||
refId: reference.refId as string,
|
||||
} satisfies CanvasGenerationInputReference,
|
||||
];
|
||||
});
|
||||
if (
|
||||
strictWhitelist &&
|
||||
(fields.length !== rawFields.length ||
|
||||
references.length !== rawReferences.length)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return strictWhitelist || fields.length || references.length || soundEffect
|
||||
? { fields, references, ...(soundEffect ? { soundEffect } : {}) }
|
||||
: null;
|
||||
}
|
||||
|
||||
export type CanvasGenerationInputsHydrationResult = {
|
||||
state: CanvasGenerationInputsHydrationState;
|
||||
inputs: CanvasGenerationInputs | null;
|
||||
};
|
||||
|
||||
export function hydrateCanvasGenerationInputsResult(
|
||||
value: unknown,
|
||||
options: { strictWhitelist?: boolean } = {},
|
||||
): CanvasGenerationInputsHydrationResult {
|
||||
if (!isRecord(value)) {
|
||||
return { state: 'absent', inputs: null };
|
||||
}
|
||||
if ('version' in value || 'action' in value) {
|
||||
const inputs = cloneV2GenerationInputs(
|
||||
value,
|
||||
options.strictWhitelist === true,
|
||||
);
|
||||
return inputs
|
||||
? { state: 'valid', inputs }
|
||||
: { state: 'invalid-versioned', inputs: null };
|
||||
}
|
||||
const inputs = hydrateLegacyGenerationInputs(
|
||||
value,
|
||||
options.strictWhitelist === true,
|
||||
);
|
||||
return inputs
|
||||
? { state: 'valid', inputs }
|
||||
: { state: 'absent', inputs: null };
|
||||
}
|
||||
|
||||
export function hydrateCanvasGenerationInputs(
|
||||
value: unknown,
|
||||
options: { strictWhitelist?: boolean } = {},
|
||||
): CanvasGenerationInputs | null {
|
||||
return hydrateCanvasGenerationInputsResult(value, options).inputs;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { createRef, useState } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { EDITOR_ICON_DESCRIPTION_MAX_CHARS } from '../../services/image-editor/editorProjectClient';
|
||||
import {
|
||||
getPlainTextEditorHost,
|
||||
setPlainTextEditorValue,
|
||||
@@ -212,6 +213,25 @@ describe('ImageCanvasIconSpritesheetComposerView', () => {
|
||||
expect(submitIconGeneration).toHaveBeenCalledWith(dialog);
|
||||
});
|
||||
|
||||
it('limits description input by Unicode characters', () => {
|
||||
const updateIconDescriptionText = vi.fn();
|
||||
render(
|
||||
<IconComposerHarness
|
||||
initialDialog={createIconDialog({ prompt: '' })}
|
||||
onUpdateIconDescriptionText={updateIconDescriptionText}
|
||||
/>,
|
||||
);
|
||||
|
||||
setPlainTextEditorValue(
|
||||
screen.getByLabelText('素材描述'),
|
||||
'😀'.repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS + 1),
|
||||
);
|
||||
|
||||
expect(updateIconDescriptionText).toHaveBeenCalledWith(
|
||||
'😀'.repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps image option changes inside the icon dialog', () => {
|
||||
const rememberImageModel = vi.fn();
|
||||
render(
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type SetStateAction,
|
||||
} from 'react';
|
||||
|
||||
import { EDITOR_ICON_DESCRIPTION_MAX_CHARS } from '../../services/image-editor/editorProjectClient';
|
||||
import { AutoGrowTextArea } from '../common/AutoGrowTextArea';
|
||||
import {
|
||||
PlatformFloatingMenu,
|
||||
@@ -23,6 +24,10 @@ import { calculateEditorIconSpritesheetPrice } from './ImageCanvasGenerationMode
|
||||
import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
|
||||
import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss';
|
||||
|
||||
function limitIconDescriptionText(value: string) {
|
||||
return Array.from(value).slice(0, EDITOR_ICON_DESCRIPTION_MAX_CHARS).join('');
|
||||
}
|
||||
|
||||
type ImageCanvasIconSpritesheetComposerViewProps = {
|
||||
dialog: GenerateDialogState;
|
||||
style: CSSProperties;
|
||||
@@ -210,7 +215,9 @@ export function ImageCanvasIconSpritesheetComposerView({
|
||||
disabled={dialog.status === 'generating'}
|
||||
placeholder="你需要哪些图标?"
|
||||
className="image-canvas-editor__generation-prompt"
|
||||
onValueChange={onUpdateIconDescriptionText}
|
||||
onValueChange={(value) =>
|
||||
onUpdateIconDescriptionText(limitIconDescriptionText(value))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{dialog.status === 'failed' ? (
|
||||
|
||||
@@ -238,6 +238,29 @@ describe('ImageCanvasMetadataModalView', () => {
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders true and false generation input values as visible text', () => {
|
||||
render(
|
||||
<ImageCanvasMetadataModalView
|
||||
layer={createLayer({
|
||||
generationInputs: {
|
||||
version: 2,
|
||||
action: 'video.generate',
|
||||
fields: [
|
||||
{ id: 'webSearchEnabled', title: '联网搜索', value: true },
|
||||
{ id: 'soundEnabled', title: '启用声音', value: false },
|
||||
],
|
||||
references: [],
|
||||
},
|
||||
})}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '图片信息' });
|
||||
expect(within(dialog).getByText('true')).toBeTruthy();
|
||||
expect(within(dialog).getByText('false')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders audio duration from generation inputs instead of layer metadata', () => {
|
||||
render(
|
||||
<ImageCanvasMetadataModalView
|
||||
|
||||
@@ -71,14 +71,14 @@ export function ImageCanvasMetadataModalView({
|
||||
<div className="image-canvas-editor__metadata-reference-list">
|
||||
{generationInputReferences.map((reference) => (
|
||||
<div
|
||||
key={`${reference.title}-${reference.label}-${reference.refType}-${reference.refId}`}
|
||||
key={`${reference.title}-${reference.label ?? ''}-${reference.refType}-${reference.refId}`}
|
||||
className="image-canvas-editor__metadata-reference-card"
|
||||
>
|
||||
<span className="image-canvas-editor__metadata-reference-copy">
|
||||
<span className="image-canvas-editor__metadata-input-title">
|
||||
{reference.title}
|
||||
</span>
|
||||
<span>{reference.label}</span>
|
||||
<span>{reference.label?.trim() || '参考素材'}</span>
|
||||
<span className="image-canvas-editor__metadata-reference-id">
|
||||
{formatReferenceType(reference.refType)} ·{' '}
|
||||
{reference.refId}
|
||||
@@ -94,9 +94,7 @@ export function ImageCanvasMetadataModalView({
|
||||
)}
|
||||
</dd>
|
||||
<dt>Model</dt>
|
||||
<dd>
|
||||
{getEditorLayerModelDisplayName(layer.model)}
|
||||
</dd>
|
||||
<dd>{getEditorLayerModelDisplayName(layer.model)}</dd>
|
||||
{layer.mediaType !== 'audio' ? (
|
||||
<>
|
||||
<dt>Resolution</dt>
|
||||
|
||||
@@ -25,7 +25,13 @@ function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
|
||||
originalWidth: 320,
|
||||
originalHeight: 240,
|
||||
zIndex: 1,
|
||||
sourceType: 'uploaded',
|
||||
sourceType: 'generated',
|
||||
generationInputs: {
|
||||
version: 2,
|
||||
action: 'image.generate',
|
||||
fields: [{ id: 'prompt', title: '生成提示词', value: '测试图片' }],
|
||||
references: [],
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -35,8 +41,9 @@ function renderSelectedToolbar(
|
||||
Parameters<typeof ImageCanvasSelectedLayerToolbarView>[0]
|
||||
> = {},
|
||||
) {
|
||||
const selectedLayer = createLayer();
|
||||
const props: Parameters<typeof ImageCanvasSelectedLayerToolbarView>[0] = {
|
||||
selectedLayer: createLayer(),
|
||||
selectedLayer,
|
||||
selectedToolbarStyle: { left: 12, top: 24 },
|
||||
onOpenQuickEditPanel: vi.fn(),
|
||||
onOpenRedrawPanel: vi.fn(),
|
||||
@@ -91,7 +98,9 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
);
|
||||
expect(props.onRemoveBackground).toHaveBeenCalledWith(props.selectedLayer);
|
||||
expect(props.onPerfectPixel).toHaveBeenCalledWith(props.selectedLayer);
|
||||
expect(props.onOpenRedrawPanel).toHaveBeenCalledWith(props.selectedLayer);
|
||||
expect(props.onOpenRedrawPanel).toHaveBeenCalledWith(
|
||||
props.selectedLayer,
|
||||
);
|
||||
expect(props.onDownloadLayer).toHaveBeenCalledWith(props.selectedLayer);
|
||||
expect(
|
||||
within(toolbar).queryByRole('button', { name: '删除图片' }),
|
||||
@@ -298,6 +307,12 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
src: 'data:audio/mpeg;base64,audio',
|
||||
mediaType: 'audio',
|
||||
assetKind: 'sound-effect',
|
||||
generationInputs: {
|
||||
version: 2,
|
||||
action: 'audio.sound-effect.generate',
|
||||
fields: [{ id: 'prompt', title: 'prompt', value: '金币声' }],
|
||||
references: [],
|
||||
},
|
||||
});
|
||||
const props = renderSelectedToolbar({ selectedLayer: layer });
|
||||
const toolbar = screen.getByRole('toolbar', { name: '素材工具栏' });
|
||||
@@ -330,6 +345,77 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
expect(props.onDownloadLayer).toHaveBeenCalledWith(layer);
|
||||
});
|
||||
|
||||
it('hides redraw for non-generative derived and public display-only layers', () => {
|
||||
renderSelectedToolbar({
|
||||
selectedLayer: createLayer({
|
||||
title: '手动裁扩结果',
|
||||
generationInputs: null,
|
||||
}),
|
||||
});
|
||||
expect(screen.queryByRole('button', { name: '改造' })).toBeNull();
|
||||
|
||||
cleanup();
|
||||
renderSelectedToolbar({
|
||||
selectedLayer: createLayer({
|
||||
title: '公开音效',
|
||||
mediaType: 'audio',
|
||||
assetKind: 'sound-effect',
|
||||
generationInputs: {
|
||||
version: 2,
|
||||
fields: [{ title: 'prompt', value: '公开展示文本' }],
|
||||
references: [],
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(screen.queryByRole('button', { name: '改造' })).toBeNull();
|
||||
expect(screen.getByRole('button', { name: '下载按钮' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it.each([
|
||||
'image.perfect-pixel',
|
||||
'spritesheet.split',
|
||||
'image.remove-background',
|
||||
'image.crop-expand',
|
||||
] as const)('never exposes redraw for deterministic action %s', (action) => {
|
||||
renderSelectedToolbar({
|
||||
selectedLayer: createLayer({
|
||||
generationInputs: {
|
||||
version: 2,
|
||||
action,
|
||||
fields: [],
|
||||
references: [],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('button', { name: '改造' })).toBeNull();
|
||||
});
|
||||
|
||||
it('hides redraw for in-place quick-edit results', () => {
|
||||
renderSelectedToolbar({
|
||||
selectedLayer: createLayer({
|
||||
generationInputs: {
|
||||
version: 2,
|
||||
action: 'image.edit',
|
||||
fields: [
|
||||
{ id: 'prompt', title: '快速编辑提示词', value: '改成夜景' },
|
||||
],
|
||||
references: [
|
||||
{
|
||||
id: 'source',
|
||||
title: '原图',
|
||||
refType: 'project-resource',
|
||||
refId: 'resource-before-in-place-edit',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(screen.getByRole('button', { name: '快速编辑' })).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '改造' })).toBeNull();
|
||||
});
|
||||
|
||||
it('removes raster edit actions for video and character animation layers', () => {
|
||||
renderSelectedToolbar({
|
||||
selectedLayer: createLayer({
|
||||
@@ -337,6 +423,12 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
src: '/generated/video.mp4',
|
||||
mediaType: 'video',
|
||||
assetKind: 'video',
|
||||
generationInputs: {
|
||||
version: 2,
|
||||
action: 'video.generate',
|
||||
fields: [{ id: 'prompt', title: '视频描述', value: '测试视频' }],
|
||||
references: [],
|
||||
},
|
||||
}),
|
||||
});
|
||||
const videoToolbar = screen.getByRole('toolbar', { name: '图片工具栏' });
|
||||
@@ -363,6 +455,12 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
src: '/generated/action/frame-01.png',
|
||||
mediaType: 'image-sequence',
|
||||
assetKind: 'character-animation',
|
||||
generationInputs: {
|
||||
version: 2,
|
||||
action: 'character-animation.generate',
|
||||
fields: [{ id: 'prompt', title: '动作描述', value: '挥手' }],
|
||||
references: [],
|
||||
},
|
||||
}),
|
||||
});
|
||||
const actionToolbar = screen.getByRole('toolbar', { name: '图片工具栏' });
|
||||
@@ -383,6 +481,61 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
).toEqual(['改造', '下载按钮']);
|
||||
});
|
||||
|
||||
it('keeps redraw visible for supported actions with a missing non-replaceable source', () => {
|
||||
const generatedLayer = createLayer({
|
||||
id: 'animation-layer',
|
||||
resourceId: 'animation-resource',
|
||||
mediaType: 'image-sequence',
|
||||
assetKind: 'character-animation',
|
||||
generationInputs: {
|
||||
version: 2,
|
||||
action: 'character-animation.generate',
|
||||
fields: [{ id: 'prompt', title: '动作描述', value: '挥手' }],
|
||||
references: [
|
||||
{
|
||||
id: 'source',
|
||||
title: '原角色图',
|
||||
label: '角色',
|
||||
refType: 'project-resource',
|
||||
refId: 'missing-source-resource',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const props = renderSelectedToolbar({ selectedLayer: generatedLayer });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '改造' }));
|
||||
|
||||
expect(props.onOpenRedrawPanel).toHaveBeenCalledWith(generatedLayer);
|
||||
|
||||
cleanup();
|
||||
const uiDesignLayer = createLayer({
|
||||
id: 'ui-spritesheet-layer',
|
||||
resourceId: 'ui-spritesheet-resource',
|
||||
assetKind: 'icon-spritesheet',
|
||||
generationInputs: {
|
||||
version: 2,
|
||||
action: 'ui-design.extract-assets',
|
||||
fields: [{ id: 'prompt', title: '提取提示词', value: '提取按钮' }],
|
||||
references: [
|
||||
{
|
||||
id: 'source',
|
||||
title: 'UI设计图',
|
||||
refType: 'project-resource',
|
||||
refId: 'missing-ui-design-resource',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const uiProps = renderSelectedToolbar({ selectedLayer: uiDesignLayer });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '改造' }));
|
||||
|
||||
expect(uiProps.onOpenRedrawPanel).toHaveBeenCalledWith(uiDesignLayer);
|
||||
});
|
||||
|
||||
it('renders character animation only for character layers', () => {
|
||||
const layer = createLayer({
|
||||
sourceType: 'generated',
|
||||
|
||||
@@ -14,7 +14,10 @@ import type { CSSProperties } from 'react';
|
||||
import { PlatformIconButton } from '../common/PlatformIconButton';
|
||||
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
|
||||
import type { CanvasLayer } from './ImageCanvasEditorTypes';
|
||||
import { isQuickEditSupportedLayer } from './ImageCanvasGenerationModel';
|
||||
import {
|
||||
canOpenRedrawPanel,
|
||||
isQuickEditSupportedLayer,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
|
||||
type ImageCanvasSelectedLayerToolbarViewProps = {
|
||||
selectedLayer: CanvasLayer | null;
|
||||
@@ -54,6 +57,7 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
if (!selectedLayer || !selectedToolbarStyle) {
|
||||
return null;
|
||||
}
|
||||
const canRedraw = canOpenRedrawPanel(selectedLayer);
|
||||
|
||||
if (selectedLayer.mediaType === 'audio') {
|
||||
return (
|
||||
@@ -64,15 +68,17 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
aria-label="素材工具栏"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<PlatformIconButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
label="改造"
|
||||
title="改造"
|
||||
icon={<WandSparkles className="h-4 w-4" />}
|
||||
onClick={() => onOpenRedrawPanel(selectedLayer)}
|
||||
>
|
||||
<span>改造</span>
|
||||
</PlatformIconButton>
|
||||
{canRedraw ? (
|
||||
<PlatformIconButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
label="改造"
|
||||
title="改造"
|
||||
icon={<WandSparkles className="h-4 w-4" />}
|
||||
onClick={() => onOpenRedrawPanel(selectedLayer)}
|
||||
>
|
||||
<span>改造</span>
|
||||
</PlatformIconButton>
|
||||
) : null}
|
||||
<EditorIconButton
|
||||
label="下载按钮"
|
||||
title="下载按钮"
|
||||
@@ -239,19 +245,23 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
<span>生成动画</span>
|
||||
</PlatformIconButton>
|
||||
) : null}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="image-canvas-editor__floating-toolbar-divider"
|
||||
/>
|
||||
<PlatformIconButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
label="改造"
|
||||
title="改造"
|
||||
icon={<WandSparkles className="h-4 w-4" />}
|
||||
onClick={() => onOpenRedrawPanel(selectedLayer)}
|
||||
>
|
||||
<span>改造</span>
|
||||
</PlatformIconButton>
|
||||
{canRedraw ? (
|
||||
<>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="image-canvas-editor__floating-toolbar-divider"
|
||||
/>
|
||||
<PlatformIconButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
label="改造"
|
||||
title="改造"
|
||||
icon={<WandSparkles className="h-4 w-4" />}
|
||||
onClick={() => onOpenRedrawPanel(selectedLayer)}
|
||||
>
|
||||
<span>改造</span>
|
||||
</PlatformIconButton>
|
||||
</>
|
||||
) : null}
|
||||
<EditorIconButton
|
||||
label="下载按钮"
|
||||
title="下载按钮"
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import { createRef, useState } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
getPlainTextEditorHost,
|
||||
@@ -15,6 +22,22 @@ import type {
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { ImageCanvasSpecGenerationPanelView } from './ImageCanvasSpecGenerationPanelView';
|
||||
|
||||
const iconSpecClientMocks = vi.hoisted(() => ({
|
||||
refineGamePlay: vi.fn(),
|
||||
refineArtStyle: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/image-editor/editorProjectClient', () => ({
|
||||
EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH: 200,
|
||||
refineEditorIconSpecPlaySetting: iconSpecClientMocks.refineGamePlay,
|
||||
refineEditorIconSpecArtStyle: iconSpecClientMocks.refineArtStyle,
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function createSpecDialog(
|
||||
patch: Partial<GenerateDialogState> = {},
|
||||
): GenerateDialogState {
|
||||
@@ -83,6 +106,57 @@ function UiDesignHarness({
|
||||
) : null;
|
||||
}
|
||||
|
||||
function IconSpecHarness({
|
||||
playSetting = '回合制占点',
|
||||
artStyle = '低多边形',
|
||||
onSubmit = vi.fn(),
|
||||
}: {
|
||||
playSetting?: string;
|
||||
artStyle?: string;
|
||||
onSubmit?: (dialog: GenerateDialogState) => void;
|
||||
}) {
|
||||
const [dialog, setDialog] = useState<GenerateDialogState>(
|
||||
createSpecDialog({
|
||||
specType: 'icon',
|
||||
specValues: {
|
||||
playSetting,
|
||||
artStyle,
|
||||
bodyRatio: '3',
|
||||
characterView: '右向斜侧身站姿',
|
||||
customPrompt: '',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<ImageCanvasSpecGenerationPanelView
|
||||
dialog={dialog}
|
||||
style={{ left: 10, top: 20 }}
|
||||
onUpdateSpecFormValue={(key, value) =>
|
||||
setDialog((current) => ({
|
||||
...current,
|
||||
specValues: {
|
||||
...current.specValues!,
|
||||
[key]: value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
onRequestUpload={vi.fn()}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe('ImageCanvasSpecGenerationPanelView', () => {
|
||||
it('keeps the reference row above fields for spec panels and shows mud point text', () => {
|
||||
renderPanel({ dialog: createSpecDialog() });
|
||||
@@ -146,9 +220,13 @@ describe('ImageCanvasSpecGenerationPanelView', () => {
|
||||
'.auto-grow-text-area__placeholder',
|
||||
)?.textContent,
|
||||
).toBe('你希望这个 UI 长什么样?');
|
||||
expect(getPlainTextEditorHost(prompt).className).toContain('auto-grow-text-area');
|
||||
expect(getPlainTextEditorHost(prompt).className).toContain(
|
||||
'auto-grow-text-area',
|
||||
);
|
||||
expect(prompt.className).not.toContain('platform-text-field');
|
||||
expect(getPlainTextEditorHost(prompt).className).toContain('image-canvas-editor__generation-prompt');
|
||||
expect(getPlainTextEditorHost(prompt).className).toContain(
|
||||
'image-canvas-editor__generation-prompt',
|
||||
);
|
||||
expect(prompt.className).not.toContain(
|
||||
'image-canvas-editor__generation-prompt--borderless',
|
||||
);
|
||||
@@ -239,7 +317,7 @@ describe('ImageCanvasSpecGenerationPanelView', () => {
|
||||
it('uses gameplay and art style hints for icon specs', () => {
|
||||
renderPanel({
|
||||
dialog: createSpecDialog({
|
||||
specType: 'ui',
|
||||
specType: 'icon',
|
||||
specValues: {
|
||||
playSetting: '',
|
||||
artStyle: '',
|
||||
@@ -250,18 +328,263 @@ describe('ImageCanvasSpecGenerationPanelView', () => {
|
||||
}),
|
||||
});
|
||||
|
||||
expect((screen.getByLabelText('玩法设定') as HTMLInputElement).value).toBe(
|
||||
'',
|
||||
);
|
||||
const playSetting = screen.getByLabelText('玩法设定');
|
||||
const artStyle = screen.getByLabelText('美术风格');
|
||||
expect(playSetting.textContent).toBe('');
|
||||
expect(
|
||||
(screen.getByLabelText('玩法设定') as HTMLInputElement).placeholder,
|
||||
).toBe('这是什么类型的游戏?');
|
||||
expect((screen.getByLabelText('美术风格') as HTMLInputElement).value).toBe(
|
||||
'',
|
||||
);
|
||||
getPlainTextEditorHost(playSetting).querySelector(
|
||||
'.auto-grow-text-area__placeholder',
|
||||
)?.textContent,
|
||||
).toBe('游戏的核心玩法是什么?');
|
||||
expect(artStyle.textContent).toBe('');
|
||||
expect(
|
||||
(screen.getByLabelText('美术风格') as HTMLInputElement).placeholder,
|
||||
getPlainTextEditorHost(artStyle).querySelector(
|
||||
'.auto-grow-text-area__placeholder',
|
||||
)?.textContent,
|
||||
).toBe('游戏的画风是怎样的?');
|
||||
expect(
|
||||
(
|
||||
screen.getByRole('button', {
|
||||
name: '一键优化玩法设定',
|
||||
}) as HTMLButtonElement
|
||||
).disabled,
|
||||
).toBe(true);
|
||||
expect(
|
||||
(
|
||||
screen.getByRole('button', {
|
||||
name: '提交生成规范',
|
||||
}) as HTMLButtonElement
|
||||
).disabled,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('enforces the icon spec prompt limit in the fields and submit state', () => {
|
||||
const overLimit = '玩'.repeat(201);
|
||||
const onSubmit = vi.fn();
|
||||
render(<IconSpecHarness onSubmit={onSubmit} />);
|
||||
|
||||
const playSetting = screen.getByLabelText('玩法设定');
|
||||
const artStyle = screen.getByLabelText('美术风格');
|
||||
expect(screen.getByLabelText('玩法设定字符计数').textContent).toBe('5/200');
|
||||
expect(screen.getByLabelText('美术风格字符计数').textContent).toBe('4/200');
|
||||
expect(screen.getByLabelText('玩法设定字符计数').className).toContain(
|
||||
'ml-auto',
|
||||
);
|
||||
expect(playSetting.hasAttribute('maxlength')).toBe(false);
|
||||
expect(artStyle.hasAttribute('maxlength')).toBe(false);
|
||||
|
||||
setPlainTextEditorValue(playSetting, overLimit);
|
||||
expect(Array.from(playSetting.textContent ?? '')).toHaveLength(200);
|
||||
setPlainTextEditorValue(playSetting, '🎮'.repeat(201));
|
||||
expect(Array.from(playSetting.textContent ?? '')).toHaveLength(200);
|
||||
expect(screen.getByLabelText('玩法设定字符计数').textContent).toBe(
|
||||
'200/200',
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交生成规范' }));
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
|
||||
cleanup();
|
||||
renderPanel({
|
||||
dialog: createSpecDialog({
|
||||
specType: 'icon',
|
||||
specValues: {
|
||||
playSetting: overLimit,
|
||||
artStyle: '低多边形',
|
||||
bodyRatio: '3',
|
||||
characterView: '右向斜侧身站姿',
|
||||
customPrompt: '',
|
||||
},
|
||||
}),
|
||||
onSubmit,
|
||||
});
|
||||
expect(
|
||||
(
|
||||
screen.getByRole('button', {
|
||||
name: '提交生成规范',
|
||||
}) as HTMLButtonElement
|
||||
).disabled,
|
||||
).toBe(true);
|
||||
expect(
|
||||
(
|
||||
screen.getByRole('button', {
|
||||
name: '一键优化玩法设定',
|
||||
}) as HTMLButtonElement
|
||||
).disabled,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('optimizes each icon spec field independently and disables generation until both finish', async () => {
|
||||
const playSettingDeferred = createDeferred<string>();
|
||||
const artStyleDeferred = createDeferred<string>();
|
||||
iconSpecClientMocks.refineGamePlay.mockReturnValueOnce(
|
||||
playSettingDeferred.promise,
|
||||
);
|
||||
iconSpecClientMocks.refineArtStyle.mockReturnValueOnce(
|
||||
artStyleDeferred.promise,
|
||||
);
|
||||
render(<IconSpecHarness />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '一键优化玩法设定' }));
|
||||
expect(
|
||||
screen.getByLabelText('玩法设定').getAttribute('aria-disabled'),
|
||||
).toBe('true');
|
||||
expect(
|
||||
screen.getByLabelText('美术风格').getAttribute('aria-disabled'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
screen.getByRole('button', { name: '一键优化玩法设定' }).textContent,
|
||||
).toBe('正在优化');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '一键优化美术风格' }));
|
||||
expect(
|
||||
(
|
||||
screen.getByRole('button', {
|
||||
name: '提交生成规范',
|
||||
}) as HTMLButtonElement
|
||||
).disabled,
|
||||
).toBe(true);
|
||||
|
||||
await act(async () => playSettingDeferred.resolve('优化后的玩法'));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText('玩法设定').textContent).toBe(
|
||||
'优化后的玩法',
|
||||
),
|
||||
);
|
||||
expect(
|
||||
(
|
||||
screen.getByRole('button', {
|
||||
name: '提交生成规范',
|
||||
}) as HTMLButtonElement
|
||||
).disabled,
|
||||
).toBe(true);
|
||||
|
||||
await act(async () => artStyleDeferred.resolve('优化后的画风'));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText('美术风格').textContent).toBe(
|
||||
'优化后的画风',
|
||||
),
|
||||
);
|
||||
expect(
|
||||
(
|
||||
screen.getByRole('button', {
|
||||
name: '提交生成规范',
|
||||
}) as HTMLButtonElement
|
||||
).disabled,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores an optimization result after the active dialog changes', async () => {
|
||||
const deferred = createDeferred<string>();
|
||||
const updateSpecFormValue = vi.fn();
|
||||
iconSpecClientMocks.refineGamePlay.mockReturnValueOnce(deferred.promise);
|
||||
const { rerender } = render(
|
||||
<ImageCanvasSpecGenerationPanelView
|
||||
dialog={createSpecDialog({ id: 'dialog-a', specType: 'icon' })}
|
||||
style={{ left: 10, top: 20 }}
|
||||
onUpdateSpecFormValue={updateSpecFormValue}
|
||||
onRequestUpload={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '一键优化玩法设定' }));
|
||||
rerender(
|
||||
<ImageCanvasSpecGenerationPanelView
|
||||
dialog={createSpecDialog({ id: 'dialog-b', specType: 'icon' })}
|
||||
style={{ left: 10, top: 20 }}
|
||||
onUpdateSpecFormValue={updateSpecFormValue}
|
||||
onRequestUpload={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await act(async () => deferred.resolve('不应写入新对话框'));
|
||||
|
||||
expect(updateSpecFormValue).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole('alert')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps one undo snapshot per icon spec field and replaces it after a later successful optimization', async () => {
|
||||
iconSpecClientMocks.refineGamePlay
|
||||
.mockResolvedValueOnce('第一次优化')
|
||||
.mockResolvedValueOnce('第二次优化');
|
||||
render(<IconSpecHarness />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '一键优化玩法设定' }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText('玩法设定').textContent).toBe('第一次优化'),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '撤销玩法设定优化' }));
|
||||
expect(screen.getByLabelText('玩法设定').textContent).toBe('回合制占点');
|
||||
expect(
|
||||
screen.queryByRole('button', { name: '撤销玩法设定优化' }),
|
||||
).toBeNull();
|
||||
|
||||
setPlainTextEditorValue(screen.getByLabelText('玩法设定'), '手动修改玩法');
|
||||
fireEvent.click(screen.getByRole('button', { name: '一键优化玩法设定' }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText('玩法设定').textContent).toBe('第二次优化'),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '撤销玩法设定优化' }));
|
||||
expect(screen.getByLabelText('玩法设定').textContent).toBe('手动修改玩法');
|
||||
});
|
||||
|
||||
it('restores the exact pre-optimization value including surrounding whitespace', async () => {
|
||||
iconSpecClientMocks.refineGamePlay.mockResolvedValueOnce('优化后的玩法');
|
||||
render(<IconSpecHarness playSetting=" 回合制占点 " />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '一键优化玩法设定' }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText('玩法设定').textContent).toBe(
|
||||
'优化后的玩法',
|
||||
),
|
||||
);
|
||||
expect(iconSpecClientMocks.refineGamePlay).toHaveBeenCalledWith(
|
||||
'回合制占点',
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '撤销玩法设定优化' }));
|
||||
expect(screen.getByLabelText('玩法设定').textContent).toBe(
|
||||
' 回合制占点 ',
|
||||
);
|
||||
});
|
||||
|
||||
it('clears the field undo snapshot after a manual edit', async () => {
|
||||
iconSpecClientMocks.refineGamePlay.mockResolvedValueOnce('成功优化');
|
||||
render(<IconSpecHarness />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '一键优化玩法设定' }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText('玩法设定').textContent).toBe('成功优化'),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole('button', { name: '撤销玩法设定优化' }),
|
||||
).toBeTruthy();
|
||||
|
||||
setPlainTextEditorValue(screen.getByLabelText('玩法设定'), '手动修改玩法');
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: '撤销玩法设定优化' }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves text and the existing valid undo snapshot unchanged when optimization fails', async () => {
|
||||
iconSpecClientMocks.refineGamePlay
|
||||
.mockResolvedValueOnce('成功优化')
|
||||
.mockRejectedValueOnce(new Error('服务暂不可用'));
|
||||
render(<IconSpecHarness />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '一键优化玩法设定' }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText('玩法设定').textContent).toBe('成功优化'),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '一键优化玩法设定' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('alert').textContent).toContain('服务暂不可用'),
|
||||
);
|
||||
expect(screen.getByLabelText('玩法设定').textContent).toBe('成功优化');
|
||||
fireEvent.click(screen.getByRole('button', { name: '撤销玩法设定优化' }));
|
||||
expect(screen.getByLabelText('玩法设定').textContent).toBe('回合制占点');
|
||||
});
|
||||
|
||||
it('renders custom prompt and hides reference upload for icon specs', () => {
|
||||
@@ -282,7 +605,9 @@ describe('ImageCanvasSpecGenerationPanelView', () => {
|
||||
|
||||
const customPrompt = screen.getByLabelText('自定义规范提示词');
|
||||
expect(customPrompt.textContent).toBe('自定义提示');
|
||||
expect(getPlainTextEditorHost(customPrompt).className).toContain('auto-grow-text-area');
|
||||
expect(getPlainTextEditorHost(customPrompt).className).toContain(
|
||||
'auto-grow-text-area',
|
||||
);
|
||||
expect(customPrompt.className).not.toContain('platform-text-field');
|
||||
expect(getPlainTextEditorHost(customPrompt).className).toContain(
|
||||
'image-canvas-editor__generation-prompt',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user