合并生成参数恢复与改造入口修复
合入 PR 135 的 GenerationInputs V2 稳定字段与严格恢复链路 统一缺失来源时保留改造入口并在点击后复检拒绝 保留主分支 SFX V2 自动时长、Loop 与权威元数据语义 将队列引用槽位与场景配方修复移植到 PR 137 的模块结构
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;
|
||||
|
||||
@@ -895,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();
|
||||
@@ -2549,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: '大鱼素材',
|
||||
}),
|
||||
@@ -2724,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设置按钮',
|
||||
}),
|
||||
@@ -3420,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();
|
||||
@@ -3431,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(
|
||||
@@ -3706,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,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。',
|
||||
@@ -3715,6 +3732,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
]),
|
||||
references: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 'source',
|
||||
title: 'UI设计图',
|
||||
label: '战斗UI设计图',
|
||||
}),
|
||||
|
||||
@@ -83,6 +83,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',
|
||||
@@ -413,6 +560,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 = [
|
||||
{
|
||||
@@ -838,6 +1022,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(
|
||||
{
|
||||
@@ -1418,6 +1648,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 会永久留在
|
||||
// 布局里;而账本在收口那一刻就被清掉了。这个组合是每一次**成功**完美像素的必然形状,
|
||||
|
||||
@@ -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:'),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1435,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
|
||||
@@ -1520,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,
|
||||
@@ -1590,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),
|
||||
@@ -1810,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,
|
||||
};
|
||||
@@ -1928,7 +1880,10 @@ export type CanvasAssetKindOverrideFallbackIssue = {
|
||||
};
|
||||
|
||||
type CanvasAssetMediaFamily =
|
||||
'image' | 'character-animation' | 'video' | 'audio';
|
||||
| 'image'
|
||||
| 'character-animation'
|
||||
| 'video'
|
||||
| 'audio';
|
||||
|
||||
function resolveCanvasAssetMediaFamily(
|
||||
assetKind: CanvasAssetKind | null | undefined,
|
||||
@@ -2178,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,
|
||||
@@ -2192,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 {
|
||||
|
||||
@@ -68,6 +68,7 @@ export type EditorAsset = {
|
||||
showcaseSubmitError?: string | null;
|
||||
assetKind?: CanvasAssetKind | null;
|
||||
generationInputs?: CanvasGenerationInputs | null;
|
||||
generationInputsHydrationState?: CanvasGenerationInputsHydrationState;
|
||||
imageSequenceFrames?: EditorImageSequenceFrameResult[];
|
||||
imageSequenceDurationMs?: number;
|
||||
uploadStatus?: 'uploading' | 'failed';
|
||||
@@ -75,24 +76,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;
|
||||
@@ -125,6 +158,7 @@ export type CanvasLayer = {
|
||||
assetKindOverride?: CanvasAssetKind | null;
|
||||
assetKind?: CanvasAssetKind | null;
|
||||
generationInputs?: CanvasGenerationInputs | null;
|
||||
generationInputsHydrationState?: CanvasGenerationInputsHydrationState;
|
||||
imageSequenceFrames?: EditorImageSequenceFrameResult[];
|
||||
imageSequenceDurationMs?: number;
|
||||
previewVideoPath?: string | null;
|
||||
@@ -229,6 +263,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,
|
||||
@@ -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({
|
||||
|
||||
@@ -537,7 +537,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 +559,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,
|
||||
})) ?? [];
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
createSpecDialogDraft,
|
||||
createUiDesignGenerationDialogDraft,
|
||||
createVideoGenerationDialogDraft,
|
||||
getUnavailableNormalizedGenerationInputReferences,
|
||||
hideGeneratedLayerComposerAfterBlur,
|
||||
updateCharacterAnimationDurationPanel,
|
||||
updateIconDescriptionsTextInDialog,
|
||||
@@ -60,6 +61,22 @@ function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
|
||||
}
|
||||
|
||||
describe('ImageCanvasGenerationDialogModel', () => {
|
||||
it('does not legacy-fallback from a hydrated invalid versioned recipe', () => {
|
||||
expect(
|
||||
createSameSourceGenerationDialogDraft({
|
||||
sourceLayer: createLayer({
|
||||
sourceType: 'generated',
|
||||
generationInputs: null,
|
||||
generationInputsHydrationState: 'invalid-versioned',
|
||||
prompt: '不能误当旧版配方',
|
||||
}),
|
||||
canvasSize: { width: 960, height: 720 },
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
mode: 'redraw',
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('creates centered drafts for image and spec generation dialogs', () => {
|
||||
const canvasSize = { width: 1000, height: 800 };
|
||||
const viewport = { x: 100, y: 40, scale: 2 };
|
||||
@@ -475,6 +492,41 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
expect(createCharacterAnimationPanelDraft(createLayer())).toBeNull();
|
||||
});
|
||||
|
||||
it('restores persisted image parameters when creating edit drafts', () => {
|
||||
const sourceLayer = createLayer({
|
||||
model: IMAGE_MODEL_NANOBANANA2,
|
||||
generationInputs: {
|
||||
version: 2,
|
||||
action: 'image.edit',
|
||||
fields: [
|
||||
{ id: 'prompt', title: '修改要求', value: '改成雨天' },
|
||||
{ id: 'model', title: '模型', value: IMAGE_MODEL_GPT_IMAGE_2 },
|
||||
{ id: 'aspectRatio', title: '比例', value: '2:3' },
|
||||
{ id: 'imageSize', title: '尺寸', value: '2K' },
|
||||
],
|
||||
references: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(createEditDialogDraft(sourceLayer)).toMatchObject({
|
||||
prompt: '改成雨天',
|
||||
imageModel: IMAGE_MODEL_GPT_IMAGE_2,
|
||||
aspectRatio: '2:3',
|
||||
imageSize: '2K',
|
||||
});
|
||||
expect(
|
||||
createEditDialogDraft(sourceLayer, {
|
||||
imageModel: IMAGE_MODEL_NANOBANANA2,
|
||||
aspectRatio: '16:9',
|
||||
imageSize: '1K',
|
||||
}),
|
||||
).toMatchObject({
|
||||
imageModel: IMAGE_MODEL_NANOBANANA2,
|
||||
aspectRatio: '16:9',
|
||||
imageSize: '1K',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves the resolved asset label in quick-edit dialog snapshots', () => {
|
||||
expect(
|
||||
createQuickEditGenerationDialogDraft({
|
||||
@@ -882,6 +934,408 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('restores V2 video and sound parameters by stable ids instead of display labels', () => {
|
||||
const imageReference = createLayer({
|
||||
id: 'layer-image-reference',
|
||||
resourceId: 'resource-image-reference',
|
||||
title: '视觉参考',
|
||||
});
|
||||
const audioReference = createLayer({
|
||||
id: 'layer-audio-reference',
|
||||
resourceId: 'resource-audio-reference',
|
||||
title: '音轨参考',
|
||||
mediaType: 'audio',
|
||||
});
|
||||
const videoReference = createLayer({
|
||||
id: 'layer-video-reference',
|
||||
resourceId: 'resource-video-reference',
|
||||
title: '源视频',
|
||||
mediaType: 'video',
|
||||
});
|
||||
const context = {
|
||||
canvasSize: { width: 960, height: 720 },
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
availableLayers: [imageReference, videoReference, audioReference],
|
||||
};
|
||||
|
||||
expect(
|
||||
createLayerGenerationDialogDraft({
|
||||
...context,
|
||||
sourceLayer: createLayer({
|
||||
sourceType: 'generated',
|
||||
mediaType: 'video',
|
||||
generationInputs: {
|
||||
version: 2,
|
||||
action: 'video.generate',
|
||||
fields: [
|
||||
{ id: 'prompt', title: '可随时改名', value: '角色追逐镜头' },
|
||||
{ id: 'model', title: '不用作键', value: 'seedance2.0-fast' },
|
||||
{ id: 'aspectRatio', title: '显示快照', value: '9:16' },
|
||||
{ id: 'resolution', title: '显示快照', value: '1080p' },
|
||||
{ id: 'durationSeconds', title: '显示快照', value: 8 },
|
||||
{ id: 'sound', title: '显示快照', value: 'off' },
|
||||
{ id: 'webSearchEnabled', title: '显示快照', value: false },
|
||||
],
|
||||
references: [
|
||||
{
|
||||
id: 'imageReference',
|
||||
title: '任意标题',
|
||||
label: '旧显示名',
|
||||
refType: 'project-resource',
|
||||
refId: 'resource-image-reference',
|
||||
},
|
||||
{
|
||||
id: 'videoReference',
|
||||
title: '任意标题',
|
||||
label: '旧显示名',
|
||||
refType: 'project-resource',
|
||||
refId: 'resource-video-reference',
|
||||
},
|
||||
{
|
||||
id: 'audioReference',
|
||||
title: '任意标题',
|
||||
label: '旧显示名',
|
||||
refType: 'project-resource',
|
||||
refId: 'resource-audio-reference',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
).toMatchObject({
|
||||
mode: 'video',
|
||||
prompt: '角色追逐镜头',
|
||||
videoModel: 'seedance2.0-fast',
|
||||
videoAspectRatio: '9:16',
|
||||
videoResolution: '480p',
|
||||
videoDurationSeconds: 8,
|
||||
videoSound: 'off',
|
||||
videoWebSearchEnabled: false,
|
||||
generationReferences: [
|
||||
expect.objectContaining({
|
||||
id: 'canvas-layer-image-reference',
|
||||
label: '视觉参考',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'canvas-layer-video-reference',
|
||||
label: '源视频',
|
||||
mediaType: 'video',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'canvas-layer-audio-reference',
|
||||
label: '音轨参考',
|
||||
mediaType: 'audio',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(
|
||||
createLayerGenerationDialogDraft({
|
||||
...context,
|
||||
sourceLayer: createLayer({
|
||||
sourceType: 'generated',
|
||||
mediaType: 'audio',
|
||||
generationInputs: {
|
||||
version: 2,
|
||||
action: 'audio.sound-effect.generate',
|
||||
fields: [
|
||||
{ id: 'prompt', title: '非 prompt 标题', value: '短促点击音' },
|
||||
{ id: 'model', title: '非 model 标题', value: 'audio1.0' },
|
||||
{ id: 'durationSeconds', title: '非 duration 标题', value: 7 },
|
||||
],
|
||||
references: [],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
).toMatchObject({
|
||||
mode: 'audio-sound-effect',
|
||||
prompt: '短促点击音',
|
||||
soundModel: 'audio1.0',
|
||||
soundDurationSeconds: 7,
|
||||
});
|
||||
});
|
||||
|
||||
it('treats references outside the current canvas as unavailable', () => {
|
||||
const availableReference = createLayer({
|
||||
id: 'layer-available-reference',
|
||||
resourceId: 'resource-available-reference',
|
||||
});
|
||||
const sourceLayer = createLayer({
|
||||
sourceType: 'generated',
|
||||
generationInputs: {
|
||||
version: 2,
|
||||
action: 'image.generate',
|
||||
fields: [{ id: 'prompt', title: '生成提示词', value: '场景图' }],
|
||||
references: [
|
||||
{
|
||||
id: 'reference',
|
||||
title: '画布参考图',
|
||||
refType: 'project-resource',
|
||||
refId: 'resource-available-reference',
|
||||
},
|
||||
{
|
||||
id: 'reference',
|
||||
title: '面板上传参考图',
|
||||
refType: 'project-resource',
|
||||
refId: 'resource-panel-upload',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
getUnavailableNormalizedGenerationInputReferences(sourceLayer, [
|
||||
availableReference,
|
||||
]),
|
||||
).toEqual([
|
||||
expect.objectContaining({
|
||||
title: '面板上传参考图',
|
||||
refId: 'resource-panel-upload',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps valid V2 fields ahead of conflicting legacy dialog and resource fallbacks', () => {
|
||||
expect(
|
||||
createLayerGenerationDialogDraft({
|
||||
sourceLayer: createLayer({
|
||||
sourceType: 'generated',
|
||||
prompt: '资源旧提示词',
|
||||
generationInputs: {
|
||||
version: 2,
|
||||
action: 'image.generate',
|
||||
fields: [
|
||||
{
|
||||
id: 'model',
|
||||
title: '任意标题',
|
||||
value: IMAGE_MODEL_GPT_IMAGE_2,
|
||||
},
|
||||
{ id: 'aspectRatio', title: '任意标题', value: '2:3' },
|
||||
{ id: 'imageSize', title: '任意标题', value: '2K' },
|
||||
],
|
||||
references: [],
|
||||
},
|
||||
}),
|
||||
sourceDialog: {
|
||||
id: 'legacy-dialog',
|
||||
mode: 'generate',
|
||||
prompt: '历史对话框提示词',
|
||||
status: 'idle',
|
||||
imageModel: IMAGE_MODEL_NANOBANANA2,
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
},
|
||||
canvasSize: { width: 960, height: 720 },
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
}),
|
||||
).toMatchObject({
|
||||
mode: 'generate',
|
||||
prompt: '',
|
||||
imageModel: IMAGE_MODEL_GPT_IMAGE_2,
|
||||
aspectRatio: '2:3',
|
||||
imageSize: '2K',
|
||||
});
|
||||
});
|
||||
|
||||
it('restores scene.generate V2 inputs into the scene composer', () => {
|
||||
const sceneReference = createLayer({
|
||||
id: 'layer-scene-reference',
|
||||
resourceId: 'resource-scene-reference',
|
||||
title: '车站参考',
|
||||
});
|
||||
|
||||
expect(
|
||||
createSameSourceGenerationDialogDraft({
|
||||
sourceLayer: createLayer({
|
||||
id: 'layer-scene-result',
|
||||
resourceId: 'resource-scene-result',
|
||||
sourceType: 'generated',
|
||||
assetKind: 'scene',
|
||||
generationInputs: {
|
||||
version: 2,
|
||||
action: 'scene.generate',
|
||||
fields: [
|
||||
{ id: 'prompt', title: '任意标题', value: '雨夜海边车站' },
|
||||
{ id: 'stylePreset', title: '任意标题', value: 'custom' },
|
||||
{ id: 'customStyle', title: '任意标题', value: '复古像素风' },
|
||||
{
|
||||
id: 'model',
|
||||
title: '任意标题',
|
||||
value: IMAGE_MODEL_GPT_IMAGE_2,
|
||||
},
|
||||
{ id: 'aspectRatio', title: '任意标题', value: '16:9' },
|
||||
{ id: 'imageSize', title: '任意标题', value: '2K' },
|
||||
],
|
||||
references: [
|
||||
{
|
||||
id: 'reference',
|
||||
title: '任意标题',
|
||||
refType: 'project-resource',
|
||||
refId: 'resource-scene-reference',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
availableLayers: [sceneReference],
|
||||
canvasSize: { width: 960, height: 720 },
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
mode: 'redraw',
|
||||
}),
|
||||
).toMatchObject({
|
||||
mode: 'scene',
|
||||
prompt: '雨夜海边车站',
|
||||
sceneStylePreset: 'custom',
|
||||
sceneCustomStyle: '复古像素风',
|
||||
imageModel: IMAGE_MODEL_GPT_IMAGE_2,
|
||||
aspectRatio: '16:9',
|
||||
imageSize: '2K',
|
||||
generationReferences: [
|
||||
expect.objectContaining({
|
||||
id: 'canvas-layer-scene-reference',
|
||||
label: '车站参考',
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('restores the narrowly scoped legacy scene recipe into the scene composer', () => {
|
||||
expect(
|
||||
createSameSourceGenerationDialogDraft({
|
||||
sourceLayer: createLayer({
|
||||
sourceType: 'generated',
|
||||
assetKind: 'scene',
|
||||
model: IMAGE_MODEL_GPT_IMAGE_2,
|
||||
originalWidth: 2048,
|
||||
originalHeight: 1152,
|
||||
generationInputs: {
|
||||
fields: [
|
||||
{ title: '画面内容', value: '雨夜海边车站' },
|
||||
{ title: '视觉风格', value: '清透水彩' },
|
||||
],
|
||||
references: [],
|
||||
},
|
||||
}),
|
||||
canvasSize: { width: 960, height: 720 },
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
mode: 'redraw',
|
||||
}),
|
||||
).toMatchObject({
|
||||
mode: 'scene',
|
||||
prompt: '雨夜海边车站',
|
||||
sceneStylePreset: 'watercolor',
|
||||
imageModel: IMAGE_MODEL_GPT_IMAGE_2,
|
||||
aspectRatio: '16:9',
|
||||
imageSize: '2K',
|
||||
});
|
||||
});
|
||||
|
||||
it('restores quick-edit references and the persisted image model', () => {
|
||||
const editSource = createLayer({
|
||||
id: 'layer-edit-source',
|
||||
resourceId: 'resource-edit-source',
|
||||
});
|
||||
const styleReference = createLayer({
|
||||
id: 'layer-style-reference',
|
||||
resourceId: 'resource-style-reference',
|
||||
title: '风格参考',
|
||||
});
|
||||
|
||||
expect(
|
||||
createLayerGenerationDialogDraft({
|
||||
sourceLayer: createLayer({
|
||||
id: 'layer-edit-result',
|
||||
resourceId: 'resource-edit-result',
|
||||
sourceType: 'generated',
|
||||
generationInputs: {
|
||||
version: 2,
|
||||
action: 'image.edit',
|
||||
fields: [
|
||||
{ id: 'prompt', title: '提示词', value: '改成雨天' },
|
||||
{ id: 'model', title: '模型', value: IMAGE_MODEL_NANOBANANA2 },
|
||||
{ id: 'aspectRatio', title: '比例', value: '16:9' },
|
||||
{ id: 'imageSize', title: '尺寸', value: '2K' },
|
||||
],
|
||||
references: [
|
||||
{
|
||||
id: 'source',
|
||||
title: '原图',
|
||||
refType: 'project-resource',
|
||||
refId: 'resource-edit-source',
|
||||
},
|
||||
{
|
||||
id: 'reference',
|
||||
title: '参考图 1',
|
||||
refType: 'project-resource',
|
||||
refId: 'resource-style-reference',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
availableLayers: [editSource, styleReference],
|
||||
canvasSize: { width: 960, height: 720 },
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
}),
|
||||
).toMatchObject({
|
||||
mode: 'quick-edit',
|
||||
prompt: '改成雨天',
|
||||
imageModel: IMAGE_MODEL_NANOBANANA2,
|
||||
aspectRatio: '16:9',
|
||||
imageSize: '2K',
|
||||
generationReferences: [
|
||||
expect.objectContaining({
|
||||
id: 'canvas-layer-style-reference',
|
||||
label: '风格参考',
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the V2 source reference ahead of a conflicting legacy animation source', () => {
|
||||
const v2Source = createLayer({
|
||||
id: 'layer-v2-character',
|
||||
resourceId: 'resource-v2-character',
|
||||
assetKind: 'character',
|
||||
});
|
||||
const legacySource = createLayer({
|
||||
id: 'layer-legacy-character',
|
||||
resourceId: 'resource-legacy-character',
|
||||
assetKind: 'character',
|
||||
});
|
||||
|
||||
expect(
|
||||
createSameSourceGenerationDialogDraft({
|
||||
sourceLayer: createLayer({
|
||||
id: 'layer-animation-result',
|
||||
sourceType: 'generated',
|
||||
assetKind: 'character-animation',
|
||||
sourceResourceId: 'resource-legacy-character',
|
||||
generationInputs: {
|
||||
version: 2,
|
||||
action: 'character-animation.generate',
|
||||
fields: [{ id: 'prompt', title: '任意标题', value: '循环奔跑' }],
|
||||
references: [
|
||||
{
|
||||
id: 'source',
|
||||
title: '任意标题',
|
||||
refType: 'project-resource',
|
||||
refId: 'resource-v2-character',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
canvasSize: { width: 960, height: 720 },
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
mode: 'redraw',
|
||||
sourceAnimationLayer: legacySource,
|
||||
availableLayers: [legacySource, v2Source],
|
||||
}),
|
||||
).toMatchObject({
|
||||
mode: 'character-animation',
|
||||
prompt: '循环奔跑',
|
||||
sourceLayerId: 'layer-v2-character',
|
||||
});
|
||||
});
|
||||
|
||||
it('inherits and normalizes quick edit model parameters from the source', () => {
|
||||
expect(
|
||||
createQuickEditPanelDraft(
|
||||
@@ -1135,7 +1589,10 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
generationReferences: [{ label: '参考图' }],
|
||||
});
|
||||
expect(
|
||||
appendGenerationReference(sceneWithReference, createLayer({ id: 'other' })),
|
||||
appendGenerationReference(
|
||||
sceneWithReference,
|
||||
createLayer({ id: 'other' }),
|
||||
),
|
||||
).toMatchObject({
|
||||
generationReferences: [{ label: '参考图' }, { label: '源图' }],
|
||||
});
|
||||
|
||||
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
@@ -1,4 +1,5 @@
|
||||
import { EDITOR_SOUND_EFFECT_MODEL } from '../../../packages/shared/src/contracts/editorAudio';
|
||||
import type { EditorSceneGenerationInputs } from '../../../packages/shared/src/contracts/editorScene';
|
||||
import {
|
||||
CUSTOM_EDITOR_SCENE_STYLE_PRESET,
|
||||
DEFAULT_EDITOR_SCENE_STYLE_PRESET,
|
||||
@@ -40,6 +41,7 @@ import {
|
||||
CHARACTER_ANIMATION_MODEL,
|
||||
DEFAULT_EDITOR_BGFILTER_SEG_MODEL,
|
||||
DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR,
|
||||
DEFAULT_IMAGE_MODEL,
|
||||
DEFAULT_SOUND_EFFECT_DURATION_SECONDS,
|
||||
DEFAULT_SPEC_FORM_VALUES,
|
||||
DEFAULT_VIDEO_ASPECT_RATIO,
|
||||
@@ -47,9 +49,14 @@ import {
|
||||
DEFAULT_VIDEO_MODEL,
|
||||
DEFAULT_VIDEO_SOUND,
|
||||
DEFAULT_VIDEO_WEB_SEARCH_ENABLED,
|
||||
EDITOR_IMAGE_DIMENSION_OPTIONS,
|
||||
EDITOR_IMAGE_MODEL_OPTIONS,
|
||||
IMAGE_MODEL_GPT_IMAGE_2,
|
||||
inferEditorImageAspectRatio,
|
||||
inferEditorImageSizeLabel,
|
||||
normalizeEditorImageModel,
|
||||
resolveCharacterAnimationSourceImageSrc,
|
||||
resolveEditorImageGenerationPixelSize,
|
||||
SEEDANCE_VIDEO_REFERENCE_LIMITS,
|
||||
SPEC_GENERATION_ASPECT_RATIO,
|
||||
SPEC_GENERATION_IMAGE_SIZE,
|
||||
@@ -72,6 +79,29 @@ export const EDITOR_SCENE_CUSTOM_STYLE_REQUIRED_ERROR = '请填写自定义画
|
||||
|
||||
export const EDITOR_GENERATED_ASSET_LABEL_MAX_CHARS = 80;
|
||||
|
||||
/**
|
||||
* 场景生成接口的历史契约只接受字符串参数;画布配方则保留原生
|
||||
* string | number | boolean 值,以便在素材元数据中无损回放。
|
||||
*/
|
||||
export function toEditorSceneGenerationInputs(
|
||||
generationInputs: CanvasGenerationInputs,
|
||||
): EditorSceneGenerationInputs {
|
||||
return {
|
||||
fields: generationInputs.fields.map(({ title, value }) => ({
|
||||
title,
|
||||
value: String(value),
|
||||
})),
|
||||
references: generationInputs.references.map(
|
||||
({ title, label, refType, refId }) => ({
|
||||
title,
|
||||
label: label ?? '',
|
||||
refType,
|
||||
refId,
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveGenerationAssetLabel(
|
||||
assetLabel: string | null | undefined,
|
||||
fallback: string,
|
||||
@@ -163,17 +193,16 @@ function buildSeedanceVideoReferenceInput(
|
||||
}
|
||||
|
||||
// 使用 map 避免嵌套 if else
|
||||
const DEFAULT_GENERATION_PROMPTS = new Map<
|
||||
GenerateDialogState['mode'],
|
||||
string
|
||||
>([
|
||||
['edit', '修改当前图片'],
|
||||
['audio-sound-effect', '游戏音效'],
|
||||
['audio-background-music', '游戏背景音乐'],
|
||||
const DEFAULT_GENERATION_PROMPTS = new Map<GenerateDialogState['mode'], string>(
|
||||
[
|
||||
['edit', '修改当前图片'],
|
||||
['audio-sound-effect', '游戏音效'],
|
||||
['audio-background-music', '游戏背景音乐'],
|
||||
],
|
||||
);
|
||||
const REQUIRED_GENERATION_PROMPT_MODES = new Set<GenerateDialogState['mode']>([
|
||||
'scene',
|
||||
]);
|
||||
const REQUIRED_GENERATION_PROMPT_MODES = new Set<
|
||||
GenerateDialogState['mode']
|
||||
>(['scene']);
|
||||
|
||||
function getDialogDefaultPrompt(mode: GenerateDialogState['mode']) {
|
||||
return DEFAULT_GENERATION_PROMPTS.get(mode) ?? 'AI 生成图片';
|
||||
@@ -189,12 +218,168 @@ export function resolveImageGenerationDialogPrompt(
|
||||
return getDialogDefaultPrompt(dialog.mode);
|
||||
}
|
||||
|
||||
function resolveOptionalFieldWithFallback<T extends string>(
|
||||
preferredValue: string | undefined | null,
|
||||
supportedValues: readonly string[],
|
||||
fallbackValues: readonly T[],
|
||||
defaultValue: T,
|
||||
) {
|
||||
const normalizedPreferred = preferredValue?.trim() ?? '';
|
||||
if (supportedValues.includes(normalizedPreferred)) {
|
||||
return normalizedPreferred as T;
|
||||
}
|
||||
return (
|
||||
fallbackValues.find((fallbackValue) =>
|
||||
supportedValues.includes(fallbackValue),
|
||||
) ?? defaultValue
|
||||
);
|
||||
}
|
||||
|
||||
function resolveSupportedFieldValue({
|
||||
preferredValues,
|
||||
supportedValues,
|
||||
fallbackValues,
|
||||
defaultValue,
|
||||
}: {
|
||||
preferredValues: Array<string | null | undefined>;
|
||||
supportedValues: readonly string[];
|
||||
fallbackValues: readonly string[];
|
||||
defaultValue: string;
|
||||
}) {
|
||||
for (const value of preferredValues) {
|
||||
const normalized = value?.trim();
|
||||
if (normalized && supportedValues.includes(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
return (
|
||||
fallbackValues.find((fallbackValue) =>
|
||||
supportedValues.includes(fallbackValue),
|
||||
) ?? defaultValue
|
||||
);
|
||||
}
|
||||
|
||||
function resolveImageEditFieldValueFromSourceLayer(
|
||||
sourceLayer: CanvasLayer,
|
||||
fieldId: string,
|
||||
) {
|
||||
const rawValue = sourceLayer.generationInputs?.fields.find(
|
||||
(field) => field.id === fieldId,
|
||||
)?.value;
|
||||
return typeof rawValue === 'string' ? rawValue : null;
|
||||
}
|
||||
|
||||
function resolveImageEditModel({
|
||||
sourceLayer,
|
||||
dialogModel,
|
||||
}: {
|
||||
sourceLayer: CanvasLayer;
|
||||
dialogModel?: string | null;
|
||||
}) {
|
||||
const normalizedDialogModel = dialogModel
|
||||
? normalizeEditorImageModel(dialogModel)
|
||||
: null;
|
||||
const normalizedSourceLayerModel = sourceLayer.model
|
||||
? normalizeEditorImageModel(sourceLayer.model)
|
||||
: null;
|
||||
const resolvedModelFromLayer = resolveImageEditFieldValueFromSourceLayer(
|
||||
sourceLayer,
|
||||
'model',
|
||||
);
|
||||
const normalizedLayerPersistedModel = resolvedModelFromLayer
|
||||
? normalizeEditorImageModel(resolvedModelFromLayer)
|
||||
: null;
|
||||
return [
|
||||
normalizedDialogModel,
|
||||
normalizedLayerPersistedModel,
|
||||
normalizedSourceLayerModel,
|
||||
].find((candidateModel) =>
|
||||
EDITOR_IMAGE_MODEL_OPTIONS.some(
|
||||
(option) => option.value === candidateModel,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveImageEditGeometry({
|
||||
sourceLayer,
|
||||
dialogImageModel,
|
||||
dialogAspectRatio,
|
||||
dialogImageSize,
|
||||
}: {
|
||||
sourceLayer: CanvasLayer;
|
||||
dialogImageModel?: string | null;
|
||||
dialogAspectRatio?: string | null;
|
||||
dialogImageSize?: string | null;
|
||||
}) {
|
||||
const model = resolveImageEditModel({
|
||||
sourceLayer,
|
||||
dialogModel: dialogImageModel,
|
||||
});
|
||||
const selectedModel = model ?? DEFAULT_IMAGE_MODEL;
|
||||
const dimensionOptions =
|
||||
EDITOR_IMAGE_DIMENSION_OPTIONS[
|
||||
selectedModel as keyof typeof EDITOR_IMAGE_DIMENSION_OPTIONS
|
||||
] ??
|
||||
EDITOR_IMAGE_DIMENSION_OPTIONS[
|
||||
DEFAULT_IMAGE_MODEL as keyof typeof EDITOR_IMAGE_DIMENSION_OPTIONS
|
||||
];
|
||||
const supportedAspectRatios =
|
||||
dimensionOptions.aspectRatios as readonly string[];
|
||||
const supportedImageSizes = dimensionOptions.imageSizes as readonly string[];
|
||||
const persistedAspectRatio = resolveImageEditFieldValueFromSourceLayer(
|
||||
sourceLayer,
|
||||
'aspectRatio',
|
||||
);
|
||||
const persistedImageSize = resolveImageEditFieldValueFromSourceLayer(
|
||||
sourceLayer,
|
||||
'imageSize',
|
||||
);
|
||||
const inferredAspectRatio = inferEditorImageAspectRatio(
|
||||
sourceLayer.originalWidth,
|
||||
sourceLayer.originalHeight,
|
||||
);
|
||||
const inferredImageSize = inferEditorImageSizeLabel(
|
||||
sourceLayer.originalWidth,
|
||||
sourceLayer.originalHeight,
|
||||
);
|
||||
const defaultImageSize = supportedImageSizes.includes('1K')
|
||||
? '1K'
|
||||
: (supportedImageSizes[0] ?? '1K');
|
||||
const aspectRatio = resolveSupportedFieldValue({
|
||||
preferredValues: [dialogAspectRatio, persistedAspectRatio],
|
||||
supportedValues: supportedAspectRatios,
|
||||
fallbackValues: [inferredAspectRatio, ...dimensionOptions.aspectRatios],
|
||||
defaultValue: '1:1',
|
||||
});
|
||||
const imageSize = resolveSupportedFieldValue({
|
||||
preferredValues: [dialogImageSize, persistedImageSize],
|
||||
supportedValues: supportedImageSizes,
|
||||
fallbackValues: [inferredImageSize, ...dimensionOptions.imageSizes],
|
||||
defaultValue: defaultImageSize,
|
||||
});
|
||||
const outputSize = resolveEditorImageGenerationPixelSize({
|
||||
model: selectedModel,
|
||||
aspectRatio,
|
||||
imageSize,
|
||||
});
|
||||
return {
|
||||
model: selectedModel,
|
||||
aspectRatio,
|
||||
imageSize,
|
||||
outputSize,
|
||||
};
|
||||
}
|
||||
|
||||
export type ImageGenerationSubmissionPlan =
|
||||
| {
|
||||
kind: 'edit';
|
||||
normalizedPrompt: string;
|
||||
sourceLayer: CanvasLayer;
|
||||
resultTitle: string;
|
||||
editInput: Pick<
|
||||
EditorImageEditInput,
|
||||
'size' | 'model' | 'aspectRatio' | 'imageSize'
|
||||
>;
|
||||
generationInputs: CanvasGenerationInputs;
|
||||
}
|
||||
| {
|
||||
@@ -233,7 +418,10 @@ export type ImageGenerationSubmissionPlan =
|
||||
kind: 'quick-edit';
|
||||
normalizedPrompt: string;
|
||||
sourceLayer: CanvasLayer;
|
||||
editInput: Pick<EditorImageEditInput, 'size' | 'model'>;
|
||||
editInput: Pick<
|
||||
EditorImageEditInput,
|
||||
'size' | 'model' | 'aspectRatio' | 'imageSize' | 'referenceImageSrcs'
|
||||
>;
|
||||
result: {
|
||||
title: string;
|
||||
assetKind?: CanvasLayer['assetKind'];
|
||||
@@ -344,6 +532,12 @@ export function buildImageGenerationSubmissionPlan({
|
||||
if (!sourceLayer) {
|
||||
throw new Error('未找到要修改的图片');
|
||||
}
|
||||
const geometry = resolveImageEditGeometry({
|
||||
sourceLayer,
|
||||
dialogImageModel: dialog.imageModel,
|
||||
dialogAspectRatio: dialog.aspectRatio,
|
||||
dialogImageSize: dialog.imageSize,
|
||||
});
|
||||
return {
|
||||
kind: 'edit',
|
||||
normalizedPrompt,
|
||||
@@ -352,10 +546,21 @@ export function buildImageGenerationSubmissionPlan({
|
||||
dialog.assetLabel,
|
||||
`${sourceLayer.title} 修改结果`,
|
||||
),
|
||||
editInput: {
|
||||
size: `${geometry.outputSize.width}x${geometry.outputSize.height}`,
|
||||
model: geometry.model,
|
||||
aspectRatio: geometry.aspectRatio,
|
||||
imageSize: geometry.imageSize,
|
||||
},
|
||||
generationInputs: buildEditGenerationInputs(
|
||||
'修改要求',
|
||||
normalizedPrompt,
|
||||
sourceLayer,
|
||||
{
|
||||
model: geometry.model,
|
||||
aspectRatio: geometry.aspectRatio,
|
||||
imageSize: geometry.imageSize,
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -365,18 +570,72 @@ export function buildImageGenerationSubmissionPlan({
|
||||
(layer) => layer.id === dialog.sourceLayerId,
|
||||
);
|
||||
if (!sourceLayer) {
|
||||
throw new Error('未找到要改造的图片');
|
||||
throw new Error('未找到要改造的原图');
|
||||
}
|
||||
const basePrompt = dialog.prompt.trim() || '快速编辑图片';
|
||||
const normalizedQuickEditPrompt = basePrompt;
|
||||
const imageModel = IMAGE_MODEL_GPT_IMAGE_2;
|
||||
const requestedImageModel = normalizeEditorImageModel(
|
||||
dialog.imageModel ?? IMAGE_MODEL_GPT_IMAGE_2,
|
||||
);
|
||||
const imageModel = EDITOR_IMAGE_MODEL_OPTIONS.some(
|
||||
(option) => option.value === requestedImageModel,
|
||||
)
|
||||
? requestedImageModel
|
||||
: IMAGE_MODEL_GPT_IMAGE_2;
|
||||
const dimensionOptions =
|
||||
EDITOR_IMAGE_DIMENSION_OPTIONS[
|
||||
imageModel as keyof typeof EDITOR_IMAGE_DIMENSION_OPTIONS
|
||||
] ?? EDITOR_IMAGE_DIMENSION_OPTIONS[IMAGE_MODEL_GPT_IMAGE_2];
|
||||
const supportedAspectRatios =
|
||||
dimensionOptions.aspectRatios as readonly string[];
|
||||
const supportedImageSizes =
|
||||
dimensionOptions.imageSizes as readonly string[];
|
||||
const inferredAspectRatio = inferEditorImageAspectRatio(
|
||||
sourceLayer.originalWidth,
|
||||
sourceLayer.originalHeight,
|
||||
);
|
||||
const inferredImageSize = inferEditorImageSizeLabel(
|
||||
sourceLayer.originalWidth,
|
||||
sourceLayer.originalHeight,
|
||||
);
|
||||
const aspectRatio = supportedAspectRatios.includes(dialog.aspectRatio ?? '')
|
||||
? dialog.aspectRatio!.trim()
|
||||
: resolveOptionalFieldWithFallback(
|
||||
inferredAspectRatio,
|
||||
supportedAspectRatios,
|
||||
dimensionOptions.aspectRatios,
|
||||
'1:1',
|
||||
);
|
||||
const imageSize = supportedImageSizes.includes(dialog.imageSize ?? '')
|
||||
? dialog.imageSize!
|
||||
: resolveOptionalFieldWithFallback(
|
||||
inferredImageSize,
|
||||
supportedImageSizes,
|
||||
dimensionOptions.imageSizes,
|
||||
'1K',
|
||||
);
|
||||
const outputSize = resolveEditorImageGenerationPixelSize({
|
||||
model: imageModel,
|
||||
aspectRatio,
|
||||
imageSize,
|
||||
});
|
||||
const references = dialog.generationReferences ?? [];
|
||||
return {
|
||||
kind: 'quick-edit',
|
||||
normalizedPrompt: normalizedQuickEditPrompt,
|
||||
sourceLayer,
|
||||
editInput: {
|
||||
size: `${sourceLayer.originalWidth}x${sourceLayer.originalHeight}`,
|
||||
size: `${outputSize.width}x${outputSize.height}`,
|
||||
model: imageModel,
|
||||
aspectRatio,
|
||||
imageSize,
|
||||
...(references.length
|
||||
? {
|
||||
referenceImageSrcs: references.map(
|
||||
resolveImageReferenceSubmissionSource,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
result: {
|
||||
title: resolveGenerationAssetLabel(
|
||||
@@ -388,7 +647,12 @@ export function buildImageGenerationSubmissionPlan({
|
||||
'快速编辑提示词',
|
||||
normalizedQuickEditPrompt,
|
||||
sourceLayer,
|
||||
[],
|
||||
references,
|
||||
{
|
||||
model: imageModel,
|
||||
aspectRatio,
|
||||
imageSize,
|
||||
},
|
||||
),
|
||||
},
|
||||
rememberImageModel: imageModel,
|
||||
@@ -413,6 +677,11 @@ export function buildImageGenerationSubmissionPlan({
|
||||
stylePreset,
|
||||
customStyle,
|
||||
references,
|
||||
{
|
||||
model: imageModel,
|
||||
aspectRatio: dialog.aspectRatio ?? '16:9',
|
||||
imageSize: dialog.imageSize ?? '1K',
|
||||
},
|
||||
);
|
||||
return {
|
||||
kind: 'scene',
|
||||
@@ -433,7 +702,7 @@ export function buildImageGenerationSubmissionPlan({
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
generationInputs,
|
||||
generationInputs: toEditorSceneGenerationInputs(generationInputs),
|
||||
},
|
||||
result: {
|
||||
assetKind: 'scene',
|
||||
@@ -554,6 +823,12 @@ export function buildImageGenerationSubmissionPlan({
|
||||
normalizedPrompt,
|
||||
dialog.characterSpecReference,
|
||||
dialog.characterReferences,
|
||||
{
|
||||
model: imageModel,
|
||||
style: dialog.style === 'pixelArt' ? 'pixelArt' : 'none',
|
||||
aspectRatio: dialog.aspectRatio ?? '1:1',
|
||||
imageSize: dialog.imageSize ?? '1K',
|
||||
},
|
||||
),
|
||||
},
|
||||
rememberImageModel: imageModel,
|
||||
@@ -591,6 +866,11 @@ export function buildImageGenerationSubmissionPlan({
|
||||
normalizedPrompt,
|
||||
dialog.uiDesignSpecReference,
|
||||
dialog.generationReferences,
|
||||
{
|
||||
model: imageModel,
|
||||
aspectRatio: dialog.aspectRatio ?? '16:9',
|
||||
imageSize: normalizeGptImageSize(dialog.imageSize),
|
||||
},
|
||||
),
|
||||
},
|
||||
rememberImageModel: imageModel,
|
||||
@@ -636,6 +916,7 @@ export function buildImageGenerationSubmissionPlan({
|
||||
generationInputs: buildPublicationMaterialsGenerationInputs(
|
||||
dialog.publicationGameInfo,
|
||||
dialog.publicationReferences,
|
||||
workflow.id,
|
||||
),
|
||||
},
|
||||
};
|
||||
@@ -688,8 +969,15 @@ export function buildImageGenerationSubmissionPlan({
|
||||
),
|
||||
generationInputs: buildVideoGenerationInputs(
|
||||
normalizedPrompt,
|
||||
durationSeconds,
|
||||
dialog.generationReferences,
|
||||
{
|
||||
model,
|
||||
aspectRatio,
|
||||
resolution,
|
||||
durationSeconds,
|
||||
sound,
|
||||
webSearchEnabled,
|
||||
},
|
||||
),
|
||||
},
|
||||
};
|
||||
@@ -757,6 +1045,12 @@ export function buildImageGenerationSubmissionPlan({
|
||||
generationInputs: buildImageGenerationInputs(
|
||||
normalizedPrompt,
|
||||
dialog.generationReferences,
|
||||
{
|
||||
model: imageModel,
|
||||
style: dialog.style === 'pixelArt' ? 'pixelArt' : 'none',
|
||||
aspectRatio: dialog.aspectRatio ?? '1:1',
|
||||
imageSize: dialog.imageSize ?? '1K',
|
||||
},
|
||||
),
|
||||
},
|
||||
rememberImageModel: imageModel,
|
||||
@@ -834,6 +1128,12 @@ export function buildIconSpritesheetGenerationSubmissionPlan(
|
||||
iconDescriptions,
|
||||
dialog.iconSpecReference,
|
||||
dialog.generationReferences,
|
||||
{
|
||||
model: rememberImageModel,
|
||||
style: dialog.style === 'pixelArt' ? 'pixelArt' : 'none',
|
||||
aspectRatio: dialog.aspectRatio ?? '1:1',
|
||||
imageSize: dialog.imageSize ?? '1K',
|
||||
},
|
||||
),
|
||||
rememberImageModel,
|
||||
resultTitle: resolveGenerationAssetLabel(
|
||||
|
||||
@@ -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 { isQuickEditUnsupportedAssetKind } from './ImageCanvasGenerationModel';
|
||||
import {
|
||||
canOpenRedrawPanel,
|
||||
isQuickEditUnsupportedAssetKind,
|
||||
} 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="下载按钮"
|
||||
|
||||
@@ -62,7 +62,27 @@ describe('perfectPixelOperationStore', () => {
|
||||
});
|
||||
|
||||
it('round-trips an operation for the same owner and project', () => {
|
||||
const operation = buildOperation('dialog-1');
|
||||
const operation = buildOperation('dialog-1', {
|
||||
request: {
|
||||
...buildOperation('dialog-1').request,
|
||||
generationInputs: {
|
||||
version: 2,
|
||||
action: 'image.perfect-pixel',
|
||||
fields: [
|
||||
{ id: 'scale', title: '数字兼容', value: 2 },
|
||||
{ id: 'enabled', title: '布尔兼容', value: false },
|
||||
],
|
||||
references: [
|
||||
{
|
||||
id: 'source',
|
||||
title: '原图',
|
||||
refType: 'project-resource',
|
||||
refId: 'resource-source',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
savePerfectPixelOperation(OWNER_USER_ID, PROJECT_ID, operation);
|
||||
|
||||
const ledger = readPerfectPixelOperations(OWNER_USER_ID, PROJECT_ID);
|
||||
|
||||
@@ -1110,6 +1110,46 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
expect(screen.getByTestId('quick-edit').textContent).toBe('-');
|
||||
});
|
||||
|
||||
it('uploads local quick-edit references before submitting the edit request', async () => {
|
||||
editEditorImageMock.mockResolvedValueOnce(
|
||||
createGenerated({ prompt: '参考局部素材修图' }),
|
||||
);
|
||||
render(
|
||||
<SubmissionWorkflowHarness
|
||||
initialDialog={{
|
||||
id: 'generation-dialog-quick-edit-local-reference',
|
||||
mode: 'quick-edit',
|
||||
prompt: '参考局部素材修图',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
sourceLayerId: 'layer-source',
|
||||
imageModel: 'gpt-image-2',
|
||||
generationReferences: [
|
||||
{
|
||||
id: 'local-reference',
|
||||
label: '本地参考图',
|
||||
src: 'data:image/png;base64,bG9jYWwtcmVmZXJlbmNl',
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(editEditorImageMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
referenceImageSrcs: [
|
||||
'generated-character-drafts/editor/generation-references/reference.png',
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('refreshes the wallet balance after inline image generation succeeds', async () => {
|
||||
const refreshWalletBalance = vi.fn();
|
||||
editEditorImageMock.mockResolvedValueOnce(
|
||||
@@ -1515,6 +1555,19 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
referenceVideoSrcs: [
|
||||
'generated-character-drafts/editor/generation-references/reference.png',
|
||||
],
|
||||
generationInputs: expect.objectContaining({
|
||||
version: 2,
|
||||
action: 'video.generate',
|
||||
references: [
|
||||
{
|
||||
id: 'videoReference',
|
||||
title: '参考视频 1',
|
||||
label: '源视频',
|
||||
refType: 'project-resource',
|
||||
refId: 'resource-source',
|
||||
},
|
||||
],
|
||||
}),
|
||||
assetFolderId: 'project',
|
||||
assetLabel: '源视频 快速编辑',
|
||||
}),
|
||||
@@ -1777,7 +1830,13 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
generationInputs: {
|
||||
fields: [
|
||||
{ title: '画面内容', value: '雨夜中的欧洲小镇街道' },
|
||||
{ title: '视觉风格', value: '日系动画' },
|
||||
{ title: '视觉风格', value: 'anime' },
|
||||
{
|
||||
title: '模型',
|
||||
value: 'gemini-3.1-flash-image-preview',
|
||||
},
|
||||
{ title: '比例', value: '16:9' },
|
||||
{ title: '尺寸', value: '1K' },
|
||||
],
|
||||
references: [],
|
||||
},
|
||||
@@ -2337,7 +2396,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
expect(screen.getByTestId('sound-effect-assist').textContent).toBe(
|
||||
'idle',
|
||||
);
|
||||
expect(screen.getByTestId('layers').textContent).toContain(
|
||||
expect(screen.getByTestId('layers').textContent).not.toContain(
|
||||
'sound-effect',
|
||||
);
|
||||
});
|
||||
@@ -2386,22 +2445,25 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
expect(generateEditorBackgroundMusicMock).toHaveBeenCalledTimes(1);
|
||||
expect(generateEditorBackgroundMusicMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
gptDescriptionPrompt: canonicalPrompt,
|
||||
makeInstrumental: true,
|
||||
generationInputs: {
|
||||
fields: [
|
||||
{
|
||||
title: 'gpt_description_prompt',
|
||||
value: canonicalPrompt,
|
||||
},
|
||||
],
|
||||
references: [],
|
||||
},
|
||||
assetLabel: '游戏背景音乐 1',
|
||||
}),
|
||||
);
|
||||
expect(generateEditorBackgroundMusicMock).toHaveBeenCalledWith({
|
||||
gptDescriptionPrompt: canonicalPrompt,
|
||||
makeInstrumental: true,
|
||||
projectId: undefined,
|
||||
generationInputs: {
|
||||
version: 2,
|
||||
action: 'audio.background-music.generate',
|
||||
fields: [
|
||||
{
|
||||
id: 'prompt',
|
||||
title: 'gpt_description_prompt',
|
||||
value: canonicalPrompt,
|
||||
},
|
||||
],
|
||||
references: [],
|
||||
},
|
||||
assetFolderId: undefined,
|
||||
assetLabel: '游戏背景音乐 1',
|
||||
});
|
||||
expect(screen.getByTestId('tracked-dialog').textContent).toContain(
|
||||
`audio-background-music:${canonicalPrompt}:idle:open`,
|
||||
);
|
||||
@@ -2435,8 +2497,11 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
expect(generatedLayer?.prompt).toBe(canonicalPrompt);
|
||||
expect(generatedLayer?.actualPrompt).toBe(canonicalPrompt);
|
||||
expect(generatedLayer?.generationInputs).toEqual({
|
||||
version: 2,
|
||||
action: 'audio.background-music.generate',
|
||||
fields: [
|
||||
{
|
||||
id: 'prompt',
|
||||
title: 'gpt_description_prompt',
|
||||
value: canonicalPrompt,
|
||||
},
|
||||
@@ -3906,9 +3971,16 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
artStyle: '清爽卡通',
|
||||
assetLabel: '图标规范 1',
|
||||
generationInputs: {
|
||||
version: 2,
|
||||
action: 'spec.generate',
|
||||
fields: [
|
||||
{ title: '玩法设定', value: '背包整理玩法' },
|
||||
{ title: '美术风格', value: '清爽卡通' },
|
||||
{ id: 'specType', title: '规范类型', value: 'icon' },
|
||||
{
|
||||
id: 'playSetting',
|
||||
title: '玩法设定',
|
||||
value: '背包整理玩法',
|
||||
},
|
||||
{ id: 'artStyle', title: '美术风格', value: '清爽卡通' },
|
||||
],
|
||||
references: [],
|
||||
},
|
||||
@@ -4401,8 +4473,11 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
segModel: 'birefnet',
|
||||
referenceImageSrcs: ['generated/ref-style.png'],
|
||||
generationInputs: expect.objectContaining({
|
||||
version: 2,
|
||||
action: 'ui-design.extract-assets',
|
||||
references: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 'reference',
|
||||
title: '参考图 1',
|
||||
label: '风格参考',
|
||||
refType: 'project-resource',
|
||||
|
||||
@@ -66,6 +66,7 @@ import {
|
||||
buildQuickEditGenerationInputs,
|
||||
buildUiDesignAssetExtractionGenerationInputs,
|
||||
buildVideoGenerationInputs,
|
||||
createCanvasLayerReference,
|
||||
DEFAULT_EDITOR_BGFILTER_SEG_MODEL,
|
||||
DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR,
|
||||
DEFAULT_IMAGE_MODEL,
|
||||
@@ -84,6 +85,7 @@ import {
|
||||
buildIconSpritesheetGenerationSubmissionPlan,
|
||||
buildImageGenerationSubmissionPlan,
|
||||
resolveGenerationAssetLabel,
|
||||
resolveImageGenerationDialogPrompt,
|
||||
} from './ImageCanvasGenerationSubmissionModel';
|
||||
import { validateSoundEffectPrompt } from './ImageCanvasSoundEffectPromptModel';
|
||||
import type {
|
||||
@@ -303,6 +305,32 @@ const EDITOR_GENERATION_QUEUE_POLL_INTERVAL_MS = 1600;
|
||||
const EDITOR_GENERATION_QUEUE_TIMEOUT_MS = 20 * 60 * 1000;
|
||||
const EDITOR_GENERATION_QUEUE_PROJECT_REFRESH_RETRY_MS = 650;
|
||||
|
||||
function getCanvasCompletionPlaceholderSizeFromPlan({
|
||||
sourceLayer,
|
||||
outputSize,
|
||||
}: {
|
||||
sourceLayer: CanvasLayer;
|
||||
outputSize: string | null | undefined;
|
||||
}) {
|
||||
const match = /^(\d+)x(\d+)$/i.exec(outputSize?.trim() ?? '');
|
||||
if (!match) {
|
||||
return {
|
||||
width: sourceLayer.originalWidth,
|
||||
height: sourceLayer.originalHeight,
|
||||
};
|
||||
}
|
||||
const width = Number(match[1]);
|
||||
const height = Number(match[2]);
|
||||
return {
|
||||
width:
|
||||
Number.isFinite(width) && width > 0 ? width : sourceLayer.originalWidth,
|
||||
height:
|
||||
Number.isFinite(height) && height > 0
|
||||
? height
|
||||
: sourceLayer.originalHeight,
|
||||
};
|
||||
}
|
||||
|
||||
type GenerationSubmissionWorkflowOptions = {
|
||||
layers: CanvasLayer[];
|
||||
canvasSize: CanvasSize;
|
||||
@@ -1221,6 +1249,12 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
const generationInputs = buildUiDesignAssetExtractionGenerationInputs(
|
||||
sourceLayer,
|
||||
options.references,
|
||||
{
|
||||
model: options.model ?? DEFAULT_IMAGE_MODEL,
|
||||
imageSize: resolveUiAssetExtractionGenerationPlan(
|
||||
options.marks?.length ?? 0,
|
||||
).imageSize,
|
||||
},
|
||||
);
|
||||
const referenceImageSrcs = await Promise.all(
|
||||
(options.references ?? []).map((reference) =>
|
||||
@@ -1606,8 +1640,14 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
);
|
||||
const generationInputs = buildVideoGenerationInputs(
|
||||
normalizedPrompt,
|
||||
videoDurationSeconds,
|
||||
quickEditReferences,
|
||||
[createCanvasLayerReference(quickEditSourceLayer)],
|
||||
{
|
||||
model: videoModel,
|
||||
aspectRatio: '16:9',
|
||||
resolution: videoResolution,
|
||||
durationSeconds: videoDurationSeconds,
|
||||
sound: 'off',
|
||||
},
|
||||
);
|
||||
const generated = await runEditorGenerationWithWalletRefresh(
|
||||
generateEditorVideo({
|
||||
@@ -1704,12 +1744,22 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
'重绘提示词',
|
||||
normalizedPrompt,
|
||||
quickEditSourceLayer,
|
||||
{
|
||||
model: normalizedQuickEditModel,
|
||||
aspectRatio: quickEditAspectRatio,
|
||||
imageSize: quickEditImageSize,
|
||||
},
|
||||
)
|
||||
: buildQuickEditGenerationInputs(
|
||||
'快速编辑提示词',
|
||||
normalizedPrompt,
|
||||
quickEditSourceLayer,
|
||||
quickEditReferences,
|
||||
{
|
||||
model: normalizedQuickEditModel,
|
||||
aspectRatio: quickEditAspectRatio,
|
||||
imageSize: quickEditImageSize,
|
||||
},
|
||||
);
|
||||
const isCharacterRedraw =
|
||||
panelMode === 'redraw' &&
|
||||
@@ -1959,7 +2009,8 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
: null;
|
||||
const normalizedPrompt =
|
||||
backgroundMusicSubmission?.prompt ??
|
||||
soundEffectSubmission?.prompt ?? resolveImageGenerationDialogPrompt(dialog);
|
||||
soundEffectSubmission?.prompt ??
|
||||
resolveImageGenerationDialogPrompt(dialog);
|
||||
if (
|
||||
!backgroundMusicSubmission &&
|
||||
!soundEffectSubmission &&
|
||||
@@ -2008,6 +2059,11 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
'image',
|
||||
projectId,
|
||||
);
|
||||
const editPlaceholderSize =
|
||||
getCanvasCompletionPlaceholderSizeFromPlan({
|
||||
sourceLayer: submissionPlan.sourceLayer,
|
||||
outputSize: submissionPlan.editInput.size,
|
||||
});
|
||||
const canvasCompletionPlaceholder =
|
||||
getGeneratingDialogPlaceholder(dialog);
|
||||
const editCanvasCompletionPlaceholder =
|
||||
@@ -2015,17 +2071,17 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
buildRightSideCanvasCompletionPlaceholder(
|
||||
submissionPlan.sourceLayer,
|
||||
{
|
||||
width: submissionPlan.sourceLayer.originalWidth,
|
||||
height: submissionPlan.sourceLayer.originalHeight,
|
||||
originalWidth: submissionPlan.sourceLayer.originalWidth,
|
||||
originalHeight: submissionPlan.sourceLayer.originalHeight,
|
||||
width: editPlaceholderSize.width,
|
||||
height: editPlaceholderSize.height,
|
||||
originalWidth: editPlaceholderSize.width,
|
||||
originalHeight: editPlaceholderSize.height,
|
||||
},
|
||||
);
|
||||
const generated = await runEditorGenerationWithWalletRefresh(
|
||||
editEditorImage({
|
||||
prompt: submissionPlan.normalizedPrompt,
|
||||
sourceImageSrc: referenceImageSrc,
|
||||
size: `${submissionPlan.sourceLayer.originalWidth}x${submissionPlan.sourceLayer.originalHeight}`,
|
||||
...submissionPlan.editInput,
|
||||
projectId,
|
||||
assetKind: submissionPlan.sourceLayer.assetKind,
|
||||
generationInputs: submissionPlan.generationInputs,
|
||||
@@ -2065,6 +2121,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
generationInputs: submissionPlan.generationInputs,
|
||||
});
|
||||
} else if (submissionPlan.kind === 'quick-edit') {
|
||||
const isRedrawAsNewArtifact = Boolean(dialog.isRedrawAsNewArtifact);
|
||||
const quickEditSelectionMarks =
|
||||
quickEditSelectionState?.sourceLayerId ===
|
||||
submissionPlan.sourceLayer.id
|
||||
@@ -2094,18 +2151,59 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
projectId,
|
||||
);
|
||||
}
|
||||
const normalizedReferenceImageSrcs = await Promise.all(
|
||||
(submissionPlan.editInput.referenceImageSrcs ?? []).map(
|
||||
(referenceImageSrc, index) =>
|
||||
resolveEditorGenerationMediaReference(
|
||||
dialog.generationReferences?.[index]
|
||||
? {
|
||||
...dialog.generationReferences[index],
|
||||
src: referenceImageSrc,
|
||||
}
|
||||
: { src: referenceImageSrc },
|
||||
'image',
|
||||
projectId,
|
||||
),
|
||||
),
|
||||
);
|
||||
const quickEditPlaceholderSize =
|
||||
getCanvasCompletionPlaceholderSizeFromPlan({
|
||||
sourceLayer: submissionPlan.sourceLayer,
|
||||
outputSize: submissionPlan.editInput.size,
|
||||
});
|
||||
const generated = await runEditorGenerationWithWalletRefresh(
|
||||
editEditorImage({
|
||||
prompt: submissionPlan.normalizedPrompt,
|
||||
sourceImageSrc: referenceImageSrc,
|
||||
...submissionPlan.editInput,
|
||||
...(normalizedReferenceImageSrcs.length
|
||||
? { referenceImageSrcs: normalizedReferenceImageSrcs }
|
||||
: {}),
|
||||
projectId,
|
||||
assetKind: submissionPlan.result.assetKind,
|
||||
generationInputs: submissionPlan.result.generationInputs,
|
||||
assetFolderId,
|
||||
assetLabel: submissionPlan.result.title,
|
||||
sourceResourceId: submissionPlan.sourceLayer.resourceId,
|
||||
targetLayerId: submissionPlan.sourceLayer.id,
|
||||
...(isRedrawAsNewArtifact
|
||||
? {
|
||||
canvasCompletion: {
|
||||
dialogId: canvasDialog?.id,
|
||||
title: submissionPlan.result.title,
|
||||
placeholder:
|
||||
getGeneratingDialogPlaceholder(dialog) ??
|
||||
buildRightSideCanvasCompletionPlaceholder(
|
||||
submissionPlan.sourceLayer,
|
||||
{
|
||||
width: quickEditPlaceholderSize.width,
|
||||
height: quickEditPlaceholderSize.height,
|
||||
originalWidth: quickEditPlaceholderSize.width,
|
||||
originalHeight: quickEditPlaceholderSize.height,
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
: { targetLayerId: submissionPlan.sourceLayer.id }),
|
||||
}),
|
||||
onWalletBalanceMayHaveChanged,
|
||||
);
|
||||
@@ -2127,13 +2225,21 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
}
|
||||
return;
|
||||
}
|
||||
applyQuickEditResultToSourceLayer(
|
||||
generated,
|
||||
submissionPlan.sourceLayer,
|
||||
submissionPlan.result.generationInputs,
|
||||
canvasDialog?.id,
|
||||
submissionPlan.result.title,
|
||||
);
|
||||
if (isRedrawAsNewArtifact) {
|
||||
addGeneratedResultLayer(generated, {
|
||||
sourceLayer: submissionPlan.sourceLayer,
|
||||
title: submissionPlan.result.title,
|
||||
generationInputs: submissionPlan.result.generationInputs,
|
||||
});
|
||||
} else {
|
||||
applyQuickEditResultToSourceLayer(
|
||||
generated,
|
||||
submissionPlan.sourceLayer,
|
||||
submissionPlan.result.generationInputs,
|
||||
canvasDialog?.id,
|
||||
submissionPlan.result.title,
|
||||
);
|
||||
}
|
||||
} else if (submissionPlan.kind === 'icon-spec') {
|
||||
const iconSpecInput = submissionPlan.input;
|
||||
const canvasCompletionPlaceholder =
|
||||
@@ -2355,9 +2461,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
backgroundMusicSubmission,
|
||||
)
|
||||
) {
|
||||
notifyWalletBalanceMayHaveChanged(
|
||||
onWalletBalanceMayHaveChanged,
|
||||
);
|
||||
notifyWalletBalanceMayHaveChanged(onWalletBalanceMayHaveChanged);
|
||||
}
|
||||
}
|
||||
if (isSoundEffect && soundEffectSubmission) {
|
||||
@@ -2519,7 +2623,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
generateEditorScene({
|
||||
...sceneGenerationInput,
|
||||
projectId,
|
||||
generationInputs: submissionPlan.result.generationInputs,
|
||||
generationInputs: submissionPlan.input.generationInputs,
|
||||
assetFolderId,
|
||||
assetLabel: submissionPlan.result.title,
|
||||
...(projectId && canvasCompletionPlaceholder
|
||||
@@ -2822,6 +2926,12 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
const generationInputs = buildCharacterAnimationGenerationInputs(
|
||||
submissionPlan.promptText,
|
||||
characterAnimationSourceLayer,
|
||||
{
|
||||
resolution: characterAnimationPanel.resolution,
|
||||
ratio: characterAnimationPanel.ratio,
|
||||
frameCount: characterAnimationPanel.frameCount,
|
||||
durationSeconds: characterAnimationPanel.durationSeconds,
|
||||
},
|
||||
);
|
||||
const canvasCompletionPlaceholder = canvasDialog
|
||||
? getGeneratingDialogPlaceholder(canvasDialog)
|
||||
|
||||
@@ -27,7 +27,11 @@ import type {
|
||||
ImageContextMenuState,
|
||||
SidebarPanel,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { IMAGE_MODEL_GPT_IMAGE_2 } from './ImageCanvasGenerationModel';
|
||||
import {
|
||||
CANVAS_GENERATION_PARAMETER_FALLBACK_WARNING,
|
||||
DEFAULT_IMAGE_MODEL,
|
||||
IMAGE_MODEL_GPT_IMAGE_2,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
import {
|
||||
readPerfectPixelOperations,
|
||||
savePerfectPixelOperation,
|
||||
@@ -289,6 +293,19 @@ function createHydratedPerfectPixelDialog({
|
||||
sourceImageSrc: 'generated-images/editor/source.png',
|
||||
projectId,
|
||||
sourceResourceId: 'resource-source',
|
||||
generationInputs: {
|
||||
version: 2,
|
||||
action: 'image.perfect-pixel',
|
||||
fields: [],
|
||||
references: [
|
||||
{
|
||||
id: 'source',
|
||||
title: '原图',
|
||||
refType: 'project-resource',
|
||||
refId: 'resource-source',
|
||||
},
|
||||
],
|
||||
},
|
||||
assetLabel: '源图 · 完美像素',
|
||||
canvasCompletion: {
|
||||
dialogId: operationId,
|
||||
@@ -1535,14 +1552,14 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开音效改造' }));
|
||||
|
||||
expect(screen.getByTestId('tool').textContent).toBe('music');
|
||||
expect(screen.getByTestId('selected').textContent).toBe('layer-sound');
|
||||
expect(screen.getByTestId('selected').textContent).toBe('-');
|
||||
expect(screen.getByTestId('quick-edit').textContent).toBe('-');
|
||||
expect(screen.getByTestId('dialog').textContent).toBe(
|
||||
'audio-sound-effect:idle:open:-:placeholder',
|
||||
);
|
||||
expect(screen.getByTestId('dialog-prompt').textContent).toBe('金币跳出音');
|
||||
expect(screen.getByTestId('placeholder').textContent).toBe(
|
||||
'572:140:420:120',
|
||||
'10:-12:420:120',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1687,11 +1704,14 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
duration: 8,
|
||||
loop: false,
|
||||
generationInputs: {
|
||||
version: 2,
|
||||
action: 'audio.sound-effect.generate',
|
||||
fields: [
|
||||
{ title: '用户描述', value: '金币跳出音' },
|
||||
{ title: 'model', value: 'eleven_text_to_sound_v2' },
|
||||
{ title: '请求时长', value: '8秒' },
|
||||
{ title: 'Loop', value: '关闭' },
|
||||
{ id: 'prompt', title: '用户描述', value: '金币跳出音' },
|
||||
{ id: 'model', title: 'model', value: 'eleven_text_to_sound_v2' },
|
||||
{ id: 'durationMode', title: '时长模式', value: 'manual' },
|
||||
{ id: 'durationSeconds', title: '请求时长', value: 8 },
|
||||
{ id: 'loop', title: 'Loop', value: false },
|
||||
],
|
||||
references: [],
|
||||
},
|
||||
@@ -1707,7 +1727,7 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
'audio-sound-effect:idle:open:layer-audio-1:placeholder',
|
||||
);
|
||||
expect(screen.getByTestId('placeholder').textContent).toBe(
|
||||
'572:140:420:120',
|
||||
'10:-12:420:120',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1732,10 +1752,14 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
assetFolderId: undefined,
|
||||
assetKind: undefined,
|
||||
assetLabel: '生成图片 1',
|
||||
generationInputs: {
|
||||
fields: [{ title: '生成提示词', value: '一张生成图' }],
|
||||
generationInputs: expect.objectContaining({
|
||||
version: 2,
|
||||
action: 'image.generate',
|
||||
fields: expect.arrayContaining([
|
||||
{ id: 'prompt', title: '生成提示词', value: '一张生成图' },
|
||||
]),
|
||||
references: [],
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
await waitFor(() => {
|
||||
@@ -1815,17 +1839,22 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
assetKind: undefined,
|
||||
assetLabel: '生成图片 1',
|
||||
referenceImageSrcs: ['resource-source'],
|
||||
generationInputs: {
|
||||
fields: [{ title: '生成提示词', value: '一张生成图' }],
|
||||
generationInputs: expect.objectContaining({
|
||||
version: 2,
|
||||
action: 'image.generate',
|
||||
fields: expect.arrayContaining([
|
||||
{ id: 'prompt', title: '生成提示词', value: '一张生成图' },
|
||||
]),
|
||||
references: [
|
||||
{
|
||||
id: 'reference',
|
||||
title: '参考图 1',
|
||||
label: '画布参考图',
|
||||
refType: 'project-resource',
|
||||
refId: 'resource-source',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2272,17 +2301,22 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
sourceImageSrc: 'resource-source',
|
||||
assetLabel: '源图 快速编辑',
|
||||
sourceResourceId: 'resource-source',
|
||||
generationInputs: {
|
||||
fields: [{ title: '快速编辑提示词', value: '快速修图' }],
|
||||
generationInputs: expect.objectContaining({
|
||||
version: 2,
|
||||
action: 'image.edit',
|
||||
fields: expect.arrayContaining([
|
||||
{ id: 'prompt', title: '快速编辑提示词', value: '快速修图' },
|
||||
]),
|
||||
references: [
|
||||
{
|
||||
id: 'source',
|
||||
title: '原图',
|
||||
label: '源图',
|
||||
refType: 'project-resource',
|
||||
refId: 'resource-source',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -2382,6 +2416,18 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
height: 280,
|
||||
sourceType: 'generated',
|
||||
sourceResourceId: 'resource-source',
|
||||
generationInputs: {
|
||||
version: 2,
|
||||
action: 'image.crop-expand',
|
||||
fields: [],
|
||||
references: [
|
||||
expect.objectContaining({
|
||||
id: 'source',
|
||||
refType: 'project-resource',
|
||||
refId: 'resource-source',
|
||||
}),
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
await waitFor(() => {
|
||||
@@ -2553,7 +2599,18 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
projectId: undefined,
|
||||
targetLayerId: 'layer-source',
|
||||
assetKind: 'character',
|
||||
generationInputs: undefined,
|
||||
generationInputs: {
|
||||
version: 2,
|
||||
action: 'image.remove-background',
|
||||
fields: [],
|
||||
references: [
|
||||
expect.objectContaining({
|
||||
id: 'source',
|
||||
refType: 'project-resource',
|
||||
refId: 'resource-source',
|
||||
}),
|
||||
],
|
||||
},
|
||||
assetFolderId: undefined,
|
||||
assetLabel: '源图 去背景',
|
||||
sourceResourceId: 'resource-source',
|
||||
@@ -2600,6 +2657,18 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
projectId: 'project-1',
|
||||
targetLayerId: 'layer-source',
|
||||
assetLabel: '源图 去背景',
|
||||
generationInputs: {
|
||||
version: 2,
|
||||
action: 'image.remove-background',
|
||||
fields: [],
|
||||
references: [
|
||||
expect.objectContaining({
|
||||
id: 'source',
|
||||
refType: 'project-resource',
|
||||
refId: 'resource-source',
|
||||
}),
|
||||
],
|
||||
},
|
||||
canvasCompletion: expect.objectContaining({
|
||||
dialogId: 'generation-dialog-1',
|
||||
title: '源图 去背景',
|
||||
@@ -2732,7 +2801,18 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
projectId: 'project-1',
|
||||
sourceResourceId: 'resource-source',
|
||||
assetKind: 'character',
|
||||
generationInputs: sourceLayer.generationInputs,
|
||||
generationInputs: {
|
||||
version: 2,
|
||||
action: 'image.perfect-pixel',
|
||||
fields: [],
|
||||
references: [
|
||||
expect.objectContaining({
|
||||
id: 'source',
|
||||
refType: 'project-resource',
|
||||
refId: 'resource-source',
|
||||
}),
|
||||
],
|
||||
},
|
||||
assetLabel: '源图 · 完美像素',
|
||||
canvasCompletion: expect.objectContaining({
|
||||
dialogId: flushedOperation!.operationId,
|
||||
@@ -4827,6 +4907,255 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('warns when UI asset extraction has lost its non-replaceable source layer', () => {
|
||||
render(
|
||||
<GenerationWorkflowHarness
|
||||
initialLayers={[
|
||||
createLayer({
|
||||
title: 'UI 素材图集',
|
||||
assetKind: 'icon-spritesheet',
|
||||
sourceType: 'generated',
|
||||
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',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开图片改造' }));
|
||||
|
||||
expect(screen.getByTestId('ui-extraction').textContent).toBe('-');
|
||||
expect(screen.getByTestId('reference-pick-warning').textContent).toBe(
|
||||
'原 UI 设计图不在当前画布,当前素材提取面板无法重新选择来源设计图,无法改造。',
|
||||
);
|
||||
});
|
||||
|
||||
it('opens V2 panels without references that are not on the current canvas', () => {
|
||||
render(
|
||||
<GenerationWorkflowHarness
|
||||
initialLayers={[
|
||||
createLayer({
|
||||
sourceType: 'generated',
|
||||
generationInputs: {
|
||||
version: 2,
|
||||
action: 'image.generate',
|
||||
fields: [
|
||||
{ id: 'prompt', title: '生成提示词', value: '森林场景' },
|
||||
],
|
||||
references: [
|
||||
{
|
||||
id: 'reference',
|
||||
title: '面板上传参考图',
|
||||
refType: 'project-resource',
|
||||
refId: 'resource-panel-upload',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开图片改造' }));
|
||||
|
||||
expect(screen.getByTestId('dialog').textContent).toBe(
|
||||
'generate:idle:open:-:placeholder',
|
||||
);
|
||||
expect(screen.getByTestId('generation-references').textContent).toBe('');
|
||||
expect(screen.getByTestId('reference-pick-warning').textContent).toBe(
|
||||
`${CANVAS_GENERATION_PARAMETER_FALLBACK_WARNING} 部分原参考素材不在当前画布或来自面板上传,未恢复,请重新选择。`,
|
||||
);
|
||||
});
|
||||
|
||||
it('best-effort restores a required reference that the target panel can replace', () => {
|
||||
render(
|
||||
<GenerationWorkflowHarness
|
||||
initialLayers={[
|
||||
createLayer({
|
||||
sourceType: 'generated',
|
||||
assetKind: 'character',
|
||||
generationInputs: {
|
||||
version: 2,
|
||||
action: 'character.generate',
|
||||
fields: [
|
||||
{ id: 'prompt', title: '角色设定', value: '红发骑士' },
|
||||
{ id: 'model', title: '模型', value: DEFAULT_IMAGE_MODEL },
|
||||
{ id: 'style', title: '风格', value: 'none' },
|
||||
{ id: 'aspectRatio', title: '比例', value: '1:1' },
|
||||
{ id: 'imageSize', title: '尺寸', value: '1K' },
|
||||
],
|
||||
references: [
|
||||
{
|
||||
id: 'specReference',
|
||||
title: '角色规范',
|
||||
refType: 'project-resource',
|
||||
refId: 'resource-missing-character-spec',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开图片改造' }));
|
||||
|
||||
expect(screen.getByTestId('dialog').textContent).toContain(
|
||||
'character:idle:open',
|
||||
);
|
||||
expect(screen.getByTestId('reference-pick-warning').textContent).toBe(
|
||||
'部分原参考素材不在当前画布或来自面板上传,未恢复,请重新选择。',
|
||||
);
|
||||
});
|
||||
|
||||
it('warns and submits current defaults when V2 parameters are invalid', async () => {
|
||||
generateEditorImageMock.mockResolvedValueOnce(
|
||||
createGenerated({ prompt: '森林场景' }),
|
||||
);
|
||||
render(
|
||||
<GenerationWorkflowHarness
|
||||
initialLayers={[
|
||||
createLayer({
|
||||
sourceType: 'generated',
|
||||
generationInputs: {
|
||||
version: 2,
|
||||
action: 'image.generate',
|
||||
fields: [
|
||||
{ id: 'prompt', title: '生成提示词', value: '森林场景' },
|
||||
{ id: 'model', title: '模型', value: 'retired-image-model' },
|
||||
{ id: 'aspectRatio', title: '比例', value: '7:5' },
|
||||
{ id: 'imageSize', title: '尺寸', value: '8K' },
|
||||
],
|
||||
references: [],
|
||||
},
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开图片改造' }));
|
||||
|
||||
expect(screen.getByTestId('dialog-image-options').textContent).toBe(
|
||||
`generate:${DEFAULT_IMAGE_MODEL}:1:1:1K`,
|
||||
);
|
||||
expect(screen.getByTestId('reference-pick-warning').textContent).toBe(
|
||||
CANVAS_GENERATION_PARAMETER_FALLBACK_WARNING,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交生成' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(generateEditorImageMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: DEFAULT_IMAGE_MODEL,
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
generationInputs: expect.objectContaining({
|
||||
version: 2,
|
||||
action: 'image.generate',
|
||||
fields: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 'model',
|
||||
value: DEFAULT_IMAGE_MODEL,
|
||||
}),
|
||||
expect.objectContaining({ id: 'aspectRatio', value: '1:1' }),
|
||||
expect.objectContaining({ id: 'imageSize', value: '1K' }),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('reports malformed metadata after clicking a supported redraw action', () => {
|
||||
render(
|
||||
<GenerationWorkflowHarness
|
||||
initialLayers={[
|
||||
createLayer({
|
||||
sourceType: 'generated',
|
||||
generationInputs: {
|
||||
version: 2,
|
||||
action: 'image.generate',
|
||||
fields: [
|
||||
{
|
||||
id: 42,
|
||||
title: '生成提示词',
|
||||
value: '损坏数据',
|
||||
} as never,
|
||||
],
|
||||
references: [],
|
||||
},
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开图片改造' }));
|
||||
|
||||
expect(screen.getByTestId('dialog').textContent).toBe('-');
|
||||
expect(screen.getByTestId('reference-pick-warning').textContent).toBe(
|
||||
'保存的生成参数版本不受支持或数据损坏,无法改造。',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not legacy-fallback when hydration retained an invalid versioned recipe', () => {
|
||||
render(
|
||||
<GenerationWorkflowHarness
|
||||
initialLayers={[
|
||||
createLayer({
|
||||
sourceType: 'generated',
|
||||
generationInputs: null,
|
||||
generationInputsHydrationState: 'invalid-versioned',
|
||||
prompt: '这不是可恢复的旧版配方',
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开图片改造' }));
|
||||
|
||||
expect(screen.getByTestId('dialog').textContent).toBe('-');
|
||||
expect(screen.getByTestId('reference-pick-warning').textContent).toBe(
|
||||
'保存的生成参数版本不受支持或数据损坏,无法改造。',
|
||||
);
|
||||
});
|
||||
|
||||
it('warns when a legacy recipe is restored successfully', () => {
|
||||
render(
|
||||
<GenerationWorkflowHarness
|
||||
initialLayers={[
|
||||
createLayer({
|
||||
sourceType: 'generated',
|
||||
generationInputs: {
|
||||
fields: [{ title: '生成提示词', value: '旧版森林场景' }],
|
||||
references: [],
|
||||
},
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开图片改造' }));
|
||||
|
||||
expect(screen.getByTestId('dialog').textContent).not.toBe('-');
|
||||
expect(screen.getByTestId('reference-pick-warning').textContent).toBe(
|
||||
'已按旧版数据恢复,部分参数可能使用当前默认值。',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps character animation asset names synchronized with the dialog', () => {
|
||||
render(<GenerationWorkflowHarness />);
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user