合并生成参数恢复与改造入口修复

合入 PR 135 的 GenerationInputs V2 稳定字段与严格恢复链路
统一缺失来源时保留改造入口并在点击后复检拒绝
保留主分支 SFX V2 自动时长、Loop 与权威元数据语义
将队列引用槽位与场景配方修复移植到 PR 137 的模块结构
This commit is contained in:
2026-08-08 22:03:50 +08:00
38 changed files with 5278 additions and 708 deletions
@@ -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