Files
Genarrative/src/components/image-editor/ImageCanvasGenerationDialogModel.ts
T
lhk229 2095d8686a 收口画布生图抠图选项
移除前端用户侧背景色和抠图模型选择入口,提交路径固定使用 auto 与 birefnet。

清理生成输入快照和恢复逻辑中的抠图背景色与抠图模型残留。

保留后端 BgFilter 内部兼容能力,并修正空图标描述不再兜底默认描述提交。

同步更新编辑器和项目记忆文档。
2026-07-07 04:04:37 +00:00

1555 lines
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { formatImageSizeValue } from './ImageCanvasEditorModel';
import type {
CanvasGenerationDialogState,
CanvasLayer,
CanvasViewport,
CharacterAnimationPanelState,
GenerateDialogState,
PublicationMaterialsWorkflowId,
QuickEditPanelState,
SpecFormValues,
SpecGenerationType,
} from './ImageCanvasEditorTypes';
import {
appendLimitedQuickEditReferences,
AUDIO_FRAME_DISPLAY_SIZE,
AUDIO_FRAME_ORIGINAL_SIZE,
CHARACTER_ANIMATION_DURATION_OPTIONS,
CHARACTER_ANIMATION_FRAME_DISPLAY_SIZE,
CHARACTER_ANIMATION_FRAME_ORIGINAL_SIZE,
createCanvasLayerReference,
DEFAULT_IMAGE_MODEL,
DEFAULT_PUBLICATION_GAME_INFO,
DEFAULT_SOUND_EFFECT_DURATION_SECONDS,
DEFAULT_SOUND_EFFECT_MODEL,
DEFAULT_SPEC_FORM_VALUES,
DEFAULT_VIDEO_ASPECT_RATIO,
DEFAULT_VIDEO_DURATION_SECONDS,
DEFAULT_VIDEO_MODEL,
DEFAULT_VIDEO_SOUND,
DEFAULT_VIDEO_WEB_SEARCH_ENABLED,
EDITOR_IMAGE_DIMENSION_OPTIONS,
ICON_DESCRIPTION_LIMIT,
ICON_FRAME_DISPLAY_SIZE,
ICON_FRAME_ORIGINAL_SIZE,
IMAGE_MODEL_GPT_IMAGE_2,
inferEditorImageAspectRatio,
inferEditorImageSizeLabel,
normalizeEditorImageModel,
PUBLICATION_FRAME_ORIGINAL_SIZE,
resolveEditorImageGenerationPixelSize,
resolveEditorVideoGenerationPixelSize,
SPEC_FRAME_ORIGINAL_SIZE,
} from './ImageCanvasGenerationModel';
import { getPublicationMaterialsWorkflow } from './ImageCanvasPublicationMaterialsModel';
type CanvasSize = { width: number; height: number };
type SourceGenerationDialogDraftContext = {
sourceLayer: CanvasLayer;
canvasSize: CanvasSize;
viewport: CanvasViewport;
sourceDialog?: CanvasGenerationDialogState | null;
mode: 'quick-edit' | 'redraw';
sourceAnimationLayer?: CanvasLayer | null;
};
const VIDEO_REFERENCE_LIMITS = {
image: 9,
video: 3,
audio: 3,
} as const;
function getViewportWorldCenter({
canvasSize,
viewport,
}: {
canvasSize: CanvasSize;
viewport: CanvasViewport;
}) {
const safeScale = viewport.scale > 0 ? viewport.scale : 1;
return {
x: (canvasSize.width / 2 - viewport.x) / safeScale,
y: (canvasSize.height / 2 - viewport.y) / safeScale,
};
}
function resetFailedGenerationDialog(dialog: GenerateDialogState) {
return {
...dialog,
status: dialog.status === 'failed' ? 'idle' : dialog.status,
errorMessage: dialog.status === 'failed' ? undefined : dialog.errorMessage,
};
}
function resetFailedGenerationPanel(panel: QuickEditPanelState) {
return {
...panel,
status: panel.status === 'failed' ? 'idle' : panel.status,
errorMessage: panel.status === 'failed' ? undefined : panel.errorMessage,
};
}
export function parseIconDescriptionsText(text: string): string[] {
return text
.split(/[\r\n,,、;/|]+/u)
.map((description) => description.trim())
.filter(Boolean)
.slice(0, ICON_DESCRIPTION_LIMIT);
}
export function formatIconDescriptionsText(descriptions: string[]): string {
return descriptions.join('\n');
}
function isSeedanceVideoModel(model: string | undefined) {
const resolvedModel = model ?? DEFAULT_VIDEO_MODEL;
return (
resolvedModel === 'seedance2.0' || resolvedModel === 'seedance2.0-fast'
);
}
function getReferenceMediaType(layer: CanvasLayer) {
return layer.mediaType === 'video' || layer.mediaType === 'audio'
? layer.mediaType
: 'image';
}
export function isIconSpecReferenceLayer(layer: CanvasLayer) {
if (layer.assetKind === 'icon-spec') {
return true;
}
// 中文注释:历史“UI素材规范”图层以普通 spec 保存,但语义上就是图标规范,允许继续作为图标生成参考。
if (layer.assetKind !== 'spec') {
return false;
}
return (
layer.title.includes('图标规范') ||
layer.title.includes('UI素材规范') ||
layer.generationInputs?.fields.some(
(field) =>
field.value.includes('游戏UI规范') || field.value.includes('图标规范'),
) === true
);
}
export function isCharacterSpecReferenceLayer(layer: CanvasLayer) {
if (getReferenceMediaType(layer) !== 'image' || layer.assetKind !== 'spec') {
return false;
}
return !isIconSpecReferenceLayer(layer);
}
function appendLimitedSeedanceCanvasReference(
references: NonNullable<GenerateDialogState['generationReferences']>,
layer: CanvasLayer,
) {
const mediaType = getReferenceMediaType(layer);
const currentCount = references.filter(
(reference) => (reference.mediaType ?? 'image') === mediaType,
).length;
if (currentCount >= VIDEO_REFERENCE_LIMITS[mediaType]) {
return references;
}
return [...references, createCanvasLayerReference(layer)];
}
function resolveImageDimensionDefaults(imageModel: string) {
const normalizedImageModel = normalizeEditorImageModel(imageModel);
const dimensionOptions =
EDITOR_IMAGE_DIMENSION_OPTIONS[
normalizedImageModel as keyof typeof EDITOR_IMAGE_DIMENSION_OPTIONS
] ?? EDITOR_IMAGE_DIMENSION_OPTIONS[DEFAULT_IMAGE_MODEL];
return {
aspectRatio: dimensionOptions.aspectRatios[0],
imageSize:
dimensionOptions.imageSizes.find((size) => size === '1K') ??
dimensionOptions.imageSizes[0],
};
}
function getGenerationInputFieldValues(layer: CanvasLayer) {
const values = new Map<string, string>();
for (const field of layer.generationInputs?.fields ?? []) {
const title = field.title.trim().toLowerCase();
const value = field.value.trim();
if (title && value && !values.has(title)) {
values.set(title, value);
}
}
return values;
}
export function createGenerateDialogDraft({
canvasSize,
viewport,
}: {
canvasSize: CanvasSize;
viewport: CanvasViewport;
}): Omit<CanvasGenerationDialogState, 'id'> {
const worldCenter = getViewportWorldCenter({ canvasSize, viewport });
const dimensionDefaults = resolveImageDimensionDefaults(DEFAULT_IMAGE_MODEL);
const placeholderSize = resolveEditorImageGenerationPixelSize({
model: DEFAULT_IMAGE_MODEL,
aspectRatio: dimensionDefaults.aspectRatio,
imageSize: dimensionDefaults.imageSize,
});
return {
mode: 'generate',
prompt: '',
status: 'idle',
composerOpen: true,
imageModel: DEFAULT_IMAGE_MODEL,
aspectRatio: dimensionDefaults.aspectRatio,
imageSize: dimensionDefaults.imageSize,
placeholder: {
x: worldCenter.x - placeholderSize.width / 2,
y: worldCenter.y - placeholderSize.height / 2,
width: placeholderSize.width,
height: placeholderSize.height,
originalWidth: placeholderSize.width,
originalHeight: placeholderSize.height,
},
};
}
function shouldUseSpecPlaceholderValues(specType: SpecGenerationType) {
return specType === 'character' || specType === 'ui';
}
export function createSpecDialogDraft({
canvasSize,
viewport,
specType,
}: {
canvasSize: CanvasSize;
viewport: CanvasViewport;
specType: SpecGenerationType;
}): Omit<CanvasGenerationDialogState, 'id'> {
const worldCenter = getViewportWorldCenter({ canvasSize, viewport });
return {
mode: 'spec',
prompt: '',
status: 'idle',
composerOpen: true,
specType,
specValues: {
...DEFAULT_SPEC_FORM_VALUES[specType],
...(shouldUseSpecPlaceholderValues(specType)
? { playSetting: '', artStyle: '' }
: {}),
},
placeholder: {
x: worldCenter.x - SPEC_FRAME_ORIGINAL_SIZE.width / 2,
y: worldCenter.y - SPEC_FRAME_ORIGINAL_SIZE.height / 2,
width: SPEC_FRAME_ORIGINAL_SIZE.width,
height: SPEC_FRAME_ORIGINAL_SIZE.height,
originalWidth: SPEC_FRAME_ORIGINAL_SIZE.width,
originalHeight: SPEC_FRAME_ORIGINAL_SIZE.height,
},
};
}
export function createCharacterGenerationDialogDraft({
canvasSize,
viewport,
imageModel,
}: {
canvasSize: CanvasSize;
viewport: CanvasViewport;
imageModel: string;
}): Omit<CanvasGenerationDialogState, 'id'> {
const worldCenter = getViewportWorldCenter({ canvasSize, viewport });
const normalizedImageModel = normalizeEditorImageModel(imageModel);
const dimensionDefaults = resolveImageDimensionDefaults(normalizedImageModel);
const placeholderSize = resolveEditorImageGenerationPixelSize({
model: normalizedImageModel,
aspectRatio: dimensionDefaults.aspectRatio,
imageSize: dimensionDefaults.imageSize,
});
return {
mode: 'character',
prompt: '',
status: 'idle',
composerOpen: true,
characterSpecReference: null,
characterReferences: [],
imageModel: normalizedImageModel,
aspectRatio: dimensionDefaults.aspectRatio,
imageSize: dimensionDefaults.imageSize,
placeholder: {
x: worldCenter.x - placeholderSize.width / 2,
y: worldCenter.y - placeholderSize.height / 2,
width: placeholderSize.width,
height: placeholderSize.height,
originalWidth: placeholderSize.width,
originalHeight: placeholderSize.height,
},
};
}
export function createIconGenerationDialogDraft({
canvasSize,
viewport,
imageModel,
}: {
canvasSize: CanvasSize;
viewport: CanvasViewport;
imageModel: string;
}): Omit<CanvasGenerationDialogState, 'id'> {
const worldCenter = getViewportWorldCenter({ canvasSize, viewport });
const normalizedImageModel = normalizeEditorImageModel(imageModel);
const dimensionDefaults = resolveImageDimensionDefaults(normalizedImageModel);
return {
mode: 'icon',
prompt: '',
status: 'idle',
composerOpen: true,
iconSpecReference: null,
generationReferences: [],
iconDescriptions: [],
imageModel: normalizedImageModel,
aspectRatio: dimensionDefaults.aspectRatio,
imageSize: dimensionDefaults.imageSize,
placeholder: {
x: worldCenter.x - ICON_FRAME_DISPLAY_SIZE.width / 2,
y: worldCenter.y - ICON_FRAME_DISPLAY_SIZE.height / 2,
width: ICON_FRAME_DISPLAY_SIZE.width,
height: ICON_FRAME_DISPLAY_SIZE.height,
originalWidth: ICON_FRAME_ORIGINAL_SIZE.width,
originalHeight: ICON_FRAME_ORIGINAL_SIZE.height,
},
};
}
export function createPublicationGenerationDialogDraft({
canvasSize,
viewport,
workflowId,
}: {
canvasSize: CanvasSize;
viewport: CanvasViewport;
workflowId: PublicationMaterialsWorkflowId;
}): Omit<CanvasGenerationDialogState, 'id'> {
const worldCenter = getViewportWorldCenter({ canvasSize, viewport });
const frameOriginalSize = PUBLICATION_FRAME_ORIGINAL_SIZE[workflowId];
const workflow = getPublicationMaterialsWorkflow(workflowId);
return {
mode: 'publication',
prompt: '',
status: 'idle',
composerOpen: true,
publicationWorkflowId: workflowId,
publicationGameInfo: { ...DEFAULT_PUBLICATION_GAME_INFO },
publicationReferences: [],
imageModel: IMAGE_MODEL_GPT_IMAGE_2,
aspectRatio: workflow.aspectRatio,
imageSize: workflow.imageSize,
placeholder: {
x: worldCenter.x - frameOriginalSize.width / 2,
y: worldCenter.y - frameOriginalSize.height / 2,
width: frameOriginalSize.width,
height: frameOriginalSize.height,
originalWidth: frameOriginalSize.width,
originalHeight: frameOriginalSize.height,
},
};
}
export function createVideoGenerationDialogDraft({
canvasSize,
viewport,
}: {
canvasSize: CanvasSize;
viewport: CanvasViewport;
}): Omit<CanvasGenerationDialogState, 'id'> {
const worldCenter = getViewportWorldCenter({ canvasSize, viewport });
const placeholderSize = resolveEditorVideoGenerationPixelSize({
aspectRatio: DEFAULT_VIDEO_ASPECT_RATIO,
resolution: '480p',
});
return {
mode: 'video',
prompt: '',
status: 'idle',
composerOpen: true,
generationReferences: [],
videoModel: DEFAULT_VIDEO_MODEL,
videoAspectRatio: DEFAULT_VIDEO_ASPECT_RATIO,
videoResolution: '480p',
videoDurationSeconds: DEFAULT_VIDEO_DURATION_SECONDS,
videoMode: 'std',
videoSound: DEFAULT_VIDEO_SOUND,
videoWebSearchEnabled: DEFAULT_VIDEO_WEB_SEARCH_ENABLED,
placeholder: {
x: worldCenter.x - placeholderSize.width / 2,
y: worldCenter.y - placeholderSize.height / 2,
width: placeholderSize.width,
height: placeholderSize.height,
originalWidth: placeholderSize.width,
originalHeight: placeholderSize.height,
},
};
}
export function createSoundEffectGenerationDialogDraft({
canvasSize,
viewport,
}: {
canvasSize: CanvasSize;
viewport: CanvasViewport;
}): Omit<CanvasGenerationDialogState, 'id'> {
const worldCenter = getViewportWorldCenter({ canvasSize, viewport });
return {
mode: 'audio-sound-effect',
prompt: '',
status: 'idle',
composerOpen: true,
soundModel: DEFAULT_SOUND_EFFECT_MODEL,
soundDurationSeconds: DEFAULT_SOUND_EFFECT_DURATION_SECONDS,
placeholder: {
x: worldCenter.x - AUDIO_FRAME_DISPLAY_SIZE.width / 2,
y: worldCenter.y - AUDIO_FRAME_DISPLAY_SIZE.height / 2,
width: AUDIO_FRAME_DISPLAY_SIZE.width,
height: AUDIO_FRAME_DISPLAY_SIZE.height,
originalWidth: AUDIO_FRAME_ORIGINAL_SIZE.width,
originalHeight: AUDIO_FRAME_ORIGINAL_SIZE.height,
},
};
}
export function createBackgroundMusicGenerationDialogDraft({
canvasSize,
viewport,
}: {
canvasSize: CanvasSize;
viewport: CanvasViewport;
}): Omit<CanvasGenerationDialogState, 'id'> {
const worldCenter = getViewportWorldCenter({ canvasSize, viewport });
return {
mode: 'audio-background-music',
prompt: '',
status: 'idle',
composerOpen: true,
makeInstrumental: true,
placeholder: {
x: worldCenter.x - AUDIO_FRAME_DISPLAY_SIZE.width / 2,
y: worldCenter.y - AUDIO_FRAME_DISPLAY_SIZE.height / 2,
width: AUDIO_FRAME_DISPLAY_SIZE.width,
height: AUDIO_FRAME_DISPLAY_SIZE.height,
originalWidth: AUDIO_FRAME_ORIGINAL_SIZE.width,
originalHeight: AUDIO_FRAME_ORIGINAL_SIZE.height,
},
};
}
function findGenerationInputFieldValue(
layer: CanvasLayer,
titles: readonly string[],
) {
const normalizedTitles = new Set(titles.map((title) => title.toLowerCase()));
return (
layer.generationInputs?.fields.find((field) =>
normalizedTitles.has(field.title.trim().toLowerCase()),
)?.value ?? null
);
}
const USER_PROMPT_INPUT_TITLES = new Set(
[
'生成提示词',
'视频描述',
'prompt',
'sound',
'gpt_description_prompt',
'音效提示词',
'背景音乐提示词',
'角色设定',
'用户输入',
'素材描述',
'自定义规范提示词',
'修改要求',
'快速编辑提示词',
'重绘提示词',
'动作描述',
].map((title) => title.toLowerCase()),
);
const STRUCTURED_USER_INPUT_TITLES = new Set(
[
'玩法设定',
'美术风格',
'头身比',
'角色视角',
'游戏名',
'游戏分类',
'一句话描述游戏',
].map((title) => title.toLowerCase()),
);
function resolveUserGenerationPromptSnapshot(sourceLayer: CanvasLayer) {
const fields = sourceLayer.generationInputs?.fields ?? [];
const promptField = fields.find((field) =>
USER_PROMPT_INPUT_TITLES.has(field.title.trim().toLowerCase()),
);
const promptValue = promptField?.value.trim();
if (promptValue) {
return promptValue;
}
const structuredLines = fields.flatMap((field) => {
if (!STRUCTURED_USER_INPUT_TITLES.has(field.title.trim().toLowerCase())) {
return [];
}
const value = field.value.trim();
return value ? [`${field.title.trim()}${value}`] : [];
});
return structuredLines.join('\n');
}
function resolveAudioRedrawPrompt(sourceLayer: CanvasLayer) {
return resolveUserGenerationPromptSnapshot(sourceLayer);
}
function resolveAudioRedrawMode(sourceLayer: CanvasLayer) {
if (sourceLayer.assetKind === 'background-music') {
return 'audio-background-music' as const;
}
if (sourceLayer.assetKind === 'sound-effect') {
return 'audio-sound-effect' as const;
}
if (sourceLayer.mediaType !== 'audio') {
return null;
}
return sourceLayer.title.includes('背景音乐')
? ('audio-background-music' as const)
: ('audio-sound-effect' as const);
}
function resolveAudioRedrawSoundModel(
sourceLayer: CanvasLayer,
): NonNullable<GenerateDialogState['soundModel']> {
const fieldValue = findGenerationInputFieldValue(sourceLayer, [
'model',
])?.trim();
// 中文注释:编辑器音效改造入口当前只暴露 Vidu audio1.0,历史快照里的其他模型统一回落到默认模型。
return fieldValue === DEFAULT_SOUND_EFFECT_MODEL ? 'audio1.0' : 'audio1.0';
}
function resolveAudioRedrawSoundDuration(sourceLayer: CanvasLayer) {
const fieldValue = findGenerationInputFieldValue(sourceLayer, ['duration']);
const matchedValue = fieldValue?.match(/\d+/u)?.[0];
const duration = matchedValue ? Number.parseInt(matchedValue, 10) : null;
if (!duration || !Number.isFinite(duration)) {
return DEFAULT_SOUND_EFFECT_DURATION_SECONDS;
}
return Math.min(10, Math.max(2, duration));
}
export function createAudioRedrawGenerationDialogDraft(
sourceLayer: CanvasLayer,
): Omit<CanvasGenerationDialogState, 'id'> | null {
const mode = resolveAudioRedrawMode(sourceLayer);
if (!mode) {
return null;
}
const baseDraft = {
mode,
sourceLayerId: sourceLayer.id,
prompt: resolveAudioRedrawPrompt(sourceLayer),
status: 'idle' as const,
composerOpen: true,
placeholder: {
x: sourceLayer.x + sourceLayer.width + 32,
y: sourceLayer.y,
width: AUDIO_FRAME_DISPLAY_SIZE.width,
height: AUDIO_FRAME_DISPLAY_SIZE.height,
originalWidth: AUDIO_FRAME_ORIGINAL_SIZE.width,
originalHeight: AUDIO_FRAME_ORIGINAL_SIZE.height,
},
};
if (mode === 'audio-background-music') {
return {
...baseDraft,
makeInstrumental: true,
};
}
return {
...baseDraft,
soundModel: resolveAudioRedrawSoundModel(sourceLayer),
soundDurationSeconds: resolveAudioRedrawSoundDuration(sourceLayer),
};
}
export function createVideoRedrawGenerationDialogDraft(
sourceLayer: CanvasLayer,
): Omit<CanvasGenerationDialogState, 'id'> | null {
if (sourceLayer.mediaType !== 'video') {
return null;
}
const placeholderSize = resolveEditorVideoGenerationPixelSize({
aspectRatio: DEFAULT_VIDEO_ASPECT_RATIO,
resolution: '480p',
});
return {
mode: 'video',
sourceLayerId: sourceLayer.id,
prompt: resolveUserGenerationPromptSnapshot(sourceLayer),
status: 'idle',
composerOpen: true,
generationReferences: [createCanvasLayerReference(sourceLayer)],
videoModel: DEFAULT_VIDEO_MODEL,
videoAspectRatio: DEFAULT_VIDEO_ASPECT_RATIO,
videoResolution: '480p',
videoDurationSeconds: DEFAULT_VIDEO_DURATION_SECONDS,
videoMode: 'std',
videoSound: DEFAULT_VIDEO_SOUND,
videoWebSearchEnabled: DEFAULT_VIDEO_WEB_SEARCH_ENABLED,
placeholder: {
x: sourceLayer.x + sourceLayer.width + 32,
y: sourceLayer.y,
width: placeholderSize.width,
height: placeholderSize.height,
originalWidth: placeholderSize.width,
originalHeight: placeholderSize.height,
},
};
}
function resolveGeneratedSourceDialogMode({
sourceLayer,
sourceDialog,
}: {
sourceLayer: CanvasLayer;
sourceDialog?: CanvasGenerationDialogState | null;
}): CanvasGenerationDialogState['mode'] | null {
if (sourceDialog) {
return sourceDialog.mode;
}
if (sourceLayer.assetKind === 'character') {
return 'character';
}
if (sourceLayer.assetKind === 'ui-design') {
return 'ui-design';
}
if (sourceLayer.assetKind === 'publication-material') {
return 'publication';
}
if (
sourceLayer.assetKind === 'icon' ||
sourceLayer.assetKind === 'icon-spritesheet'
) {
return 'icon';
}
if (
sourceLayer.assetKind === 'spec' ||
sourceLayer.assetKind === 'icon-spec'
) {
return 'spec';
}
if (sourceLayer.assetKind === 'character-animation') {
return 'character-animation';
}
if (sourceLayer.sourceType === 'generated') {
return 'generate';
}
return null;
}
function buildLayerGenerationPlaceholder(sourceLayer: CanvasLayer) {
return {
x: sourceLayer.x,
y: sourceLayer.y,
width: sourceLayer.width,
height: sourceLayer.height,
originalWidth: sourceLayer.originalWidth,
originalHeight: sourceLayer.originalHeight,
};
}
export function createLayerGenerationDialogDraft({
sourceLayer,
canvasSize,
viewport,
sourceDialog,
sourceAnimationLayer,
}: Omit<SourceGenerationDialogDraftContext, 'mode'>): Omit<
CanvasGenerationDialogState,
'id'
> | null {
const draft = createSameSourceGenerationDialogDraft({
sourceLayer,
canvasSize,
viewport,
sourceDialog,
sourceAnimationLayer,
mode: 'redraw',
});
if (!draft) {
return null;
}
return {
...draft,
prompt: draft.prompt || sourceLayer.prompt?.trim() || '',
imageModel: sourceDialog?.imageModel ?? sourceLayer.model ?? draft.imageModel,
status: 'idle',
composerOpen: true,
generatedLayerId: sourceLayer.id,
placeholder: buildLayerGenerationPlaceholder(sourceLayer),
};
}
function getSourceDraftPrompt({
sourceLayer,
mode,
}: {
sourceLayer: CanvasLayer;
mode: SourceGenerationDialogDraftContext['mode'];
}) {
if (mode === 'quick-edit') {
return '';
}
return resolveUserGenerationPromptSnapshot(sourceLayer);
}
function buildSourceSidePlaceholder(sourceLayer: CanvasLayer) {
return {
x: sourceLayer.x + sourceLayer.width + 32,
y: sourceLayer.y,
};
}
function placeDraftBesideSourceLayer<
T extends Omit<CanvasGenerationDialogState, 'id'>,
>(draft: T, sourceLayer: CanvasLayer): T {
if (!draft.placeholder) {
return draft;
}
return {
...draft,
placeholder: {
...draft.placeholder,
...buildSourceSidePlaceholder(sourceLayer),
},
};
}
function restoreSharedImageOptions(
draft: Omit<CanvasGenerationDialogState, 'id'>,
sourceLayer: CanvasLayer,
sourceDialog?: CanvasGenerationDialogState | null,
): Omit<CanvasGenerationDialogState, 'id'> {
const fields = getGenerationInputFieldValues(sourceLayer);
return {
...draft,
imageModel: sourceDialog?.imageModel ?? draft.imageModel,
aspectRatio:
sourceDialog?.aspectRatio ??
draft.aspectRatio ??
inferEditorImageAspectRatio(
sourceLayer.originalWidth,
sourceLayer.originalHeight,
),
imageSize:
sourceDialog?.imageSize ??
draft.imageSize ??
inferEditorImageSizeLabel(
sourceLayer.originalWidth,
sourceLayer.originalHeight,
),
};
}
function resolveSpecTypeFromSourceLayer(
sourceLayer: CanvasLayer,
sourceDialog?: CanvasGenerationDialogState | null,
): SpecGenerationType {
if (sourceDialog?.mode === 'spec' && sourceDialog.specType) {
return sourceDialog.specType;
}
if (
sourceLayer.assetKind === 'icon-spec' ||
isIconSpecReferenceLayer(sourceLayer)
) {
return 'icon';
}
const fields = getGenerationInputFieldValues(sourceLayer);
if (fields.has('自定义规范提示词')) {
return 'custom';
}
if (fields.has('头身比') || fields.has('角色视角')) {
return 'character';
}
return 'ui';
}
function restoreSpecValuesFromLayer(
draft: Omit<CanvasGenerationDialogState, 'id'>,
sourceLayer: CanvasLayer,
specType: SpecGenerationType,
sourceDialog?: CanvasGenerationDialogState | null,
): Omit<CanvasGenerationDialogState, 'id'> {
const fields = getGenerationInputFieldValues(sourceLayer);
const defaultValues = DEFAULT_SPEC_FORM_VALUES[specType];
return {
...draft,
specValues: {
...defaultValues,
...sourceDialog?.specValues,
playSetting:
fields.get('玩法设定') ??
sourceDialog?.specValues?.playSetting ??
defaultValues.playSetting,
artStyle:
fields.get('美术风格') ??
sourceDialog?.specValues?.artStyle ??
defaultValues.artStyle,
bodyRatio:
fields.get('头身比') ??
sourceDialog?.specValues?.bodyRatio ??
defaultValues.bodyRatio,
characterView:
fields.get('角色视角') ??
sourceDialog?.specValues?.characterView ??
defaultValues.characterView,
customPrompt:
fields.get('自定义规范提示词') ??
sourceDialog?.specValues?.customPrompt ??
defaultValues.customPrompt,
},
};
}
function resolvePublicationWorkflowIdFromLayer(
sourceLayer: CanvasLayer,
sourceDialog?: CanvasGenerationDialogState | null,
): PublicationMaterialsWorkflowId {
if (
sourceDialog?.mode === 'publication' &&
sourceDialog.publicationWorkflowId
) {
return sourceDialog.publicationWorkflowId;
}
const ratio = inferEditorImageAspectRatio(
sourceLayer.originalWidth,
sourceLayer.originalHeight,
);
if (ratio === '9:16') {
return 'publication-detail-gallery';
}
if (ratio === '16:9') {
return 'publication-promo-poster';
}
return 'publication-cover-image';
}
function restorePublicationGameInfoFromLayer(
draft: Omit<CanvasGenerationDialogState, 'id'>,
sourceLayer: CanvasLayer,
sourceDialog?: CanvasGenerationDialogState | null,
): Omit<CanvasGenerationDialogState, 'id'> {
const fields = getGenerationInputFieldValues(sourceLayer);
return {
...draft,
publicationGameInfo: {
...DEFAULT_PUBLICATION_GAME_INFO,
...sourceDialog?.publicationGameInfo,
gameName:
fields.get('游戏名') ??
sourceDialog?.publicationGameInfo?.gameName ??
DEFAULT_PUBLICATION_GAME_INFO.gameName,
gameCategories:
fields.get('游戏分类') ??
sourceDialog?.publicationGameInfo?.gameCategories ??
DEFAULT_PUBLICATION_GAME_INFO.gameCategories,
gameDescription:
fields.get('一句话描述游戏') ??
sourceDialog?.publicationGameInfo?.gameDescription ??
DEFAULT_PUBLICATION_GAME_INFO.gameDescription,
},
publicationReferences: sourceDialog?.publicationReferences ?? [],
};
}
function restoreIconDescriptionsFromLayer(
draft: Omit<CanvasGenerationDialogState, 'id'>,
sourceLayer: CanvasLayer,
sourceDialog?: CanvasGenerationDialogState | null,
): Omit<CanvasGenerationDialogState, 'id'> {
const descriptions = parseIconDescriptionsText(
findGenerationInputFieldValue(sourceLayer, ['素材描述']) ??
sourceDialog?.prompt ??
'',
);
return {
...draft,
prompt: descriptions.join('\n'),
iconDescriptions: descriptions.length
? descriptions
: (sourceDialog?.iconDescriptions ?? draft.iconDescriptions),
iconSpecReference:
sourceDialog?.iconSpecReference ?? draft.iconSpecReference,
generationReferences: sourceDialog?.generationReferences ?? [],
};
}
function createSameSourceCharacterAnimationDraft({
sourceLayer,
canvasSize,
viewport,
sourceAnimationLayer,
mode,
}: SourceGenerationDialogDraftContext): Omit<
CanvasGenerationDialogState,
'id'
> | null {
const animationSourceLayer = sourceAnimationLayer ?? sourceLayer;
const draft = createCharacterAnimationGenerationDialogDraft({
canvasSize,
viewport,
layer: animationSourceLayer,
});
if (!draft) {
return null;
}
return placeDraftBesideSourceLayer(
{
...draft,
prompt: getSourceDraftPrompt({ sourceLayer, mode }),
sourceLayerId: animationSourceLayer.id,
composerOpen: true,
},
sourceLayer,
);
}
export function createSameSourceGenerationDialogDraft({
sourceLayer,
canvasSize,
viewport,
sourceDialog,
mode,
sourceAnimationLayer,
}: SourceGenerationDialogDraftContext): Omit<
CanvasGenerationDialogState,
'id'
> | null {
const sourceMode = resolveGeneratedSourceDialogMode({
sourceLayer,
sourceDialog,
});
const prompt = getSourceDraftPrompt({ sourceLayer, mode });
if (sourceMode === 'quick-edit') {
return null;
}
if (sourceMode === 'generate') {
return placeDraftBesideSourceLayer(
restoreSharedImageOptions(
{
...createGenerateDialogDraft({ canvasSize, viewport }),
prompt,
sourceLayerId: sourceLayer.id,
generationReferences:
sourceDialog?.mode === 'generate'
? (sourceDialog.generationReferences ?? [])
: [],
},
sourceLayer,
sourceDialog,
),
sourceLayer,
);
}
if (sourceMode === 'character-animation') {
return createSameSourceCharacterAnimationDraft({
sourceLayer,
canvasSize,
viewport,
sourceDialog,
mode,
sourceAnimationLayer,
});
}
if (sourceMode === 'character') {
return placeDraftBesideSourceLayer(
restoreSharedImageOptions(
{
...createCharacterGenerationDialogDraft({
canvasSize,
viewport,
imageModel:
sourceDialog?.imageModel ??
sourceLayer.model ??
DEFAULT_IMAGE_MODEL,
}),
prompt,
sourceLayerId: sourceLayer.id,
characterSpecReference:
sourceDialog?.mode === 'character'
? sourceDialog.characterSpecReference
: null,
characterReferences:
sourceDialog?.mode === 'character'
? (sourceDialog.characterReferences ?? [])
: [],
},
sourceLayer,
sourceDialog,
),
sourceLayer,
);
}
if (sourceMode === 'ui-design') {
return placeDraftBesideSourceLayer(
restoreSharedImageOptions(
{
...createUiDesignGenerationDialogDraft({
canvasSize,
viewport,
imageModel: sourceDialog?.imageModel ?? IMAGE_MODEL_GPT_IMAGE_2,
}),
prompt,
sourceLayerId: sourceLayer.id,
uiDesignSpecReference:
sourceDialog?.mode === 'ui-design'
? sourceDialog.uiDesignSpecReference
: null,
},
sourceLayer,
sourceDialog,
),
sourceLayer,
);
}
if (sourceMode === 'publication') {
const workflowId = resolvePublicationWorkflowIdFromLayer(
sourceLayer,
sourceDialog,
);
return placeDraftBesideSourceLayer(
restorePublicationGameInfoFromLayer(
{
...createPublicationGenerationDialogDraft({
canvasSize,
viewport,
workflowId,
}),
prompt,
sourceLayerId: sourceLayer.id,
},
sourceLayer,
sourceDialog,
),
sourceLayer,
);
}
if (sourceMode === 'icon') {
return placeDraftBesideSourceLayer(
restoreSharedImageOptions(
restoreIconDescriptionsFromLayer(
{
...createIconGenerationDialogDraft({
canvasSize,
viewport,
imageModel:
sourceDialog?.imageModel ??
sourceLayer.model ??
DEFAULT_IMAGE_MODEL,
}),
sourceLayerId: sourceLayer.id,
},
sourceLayer,
sourceDialog,
),
sourceLayer,
sourceDialog,
),
sourceLayer,
);
}
if (sourceMode === 'spec') {
const specType = resolveSpecTypeFromSourceLayer(sourceLayer, sourceDialog);
return placeDraftBesideSourceLayer(
restoreSpecValuesFromLayer(
{
...createSpecDialogDraft({ canvasSize, viewport, specType }),
prompt,
sourceLayerId: sourceLayer.id,
specReference:
sourceDialog?.mode === 'spec' ? sourceDialog.specReference : null,
},
sourceLayer,
specType,
sourceDialog,
),
sourceLayer,
);
}
return null;
}
export function createUiDesignGenerationDialogDraft({
canvasSize,
viewport,
imageModel,
}: {
canvasSize: CanvasSize;
viewport: CanvasViewport;
imageModel: string;
}): Omit<CanvasGenerationDialogState, 'id'> {
const worldCenter = getViewportWorldCenter({ canvasSize, viewport });
const normalizedImageModel = normalizeEditorImageModel(imageModel);
const dimensionDefaults = resolveImageDimensionDefaults(normalizedImageModel);
const placeholderSize = resolveEditorImageGenerationPixelSize({
model: normalizedImageModel,
aspectRatio: '16:9',
imageSize: dimensionDefaults.imageSize,
});
return {
mode: 'ui-design',
prompt: '',
status: 'idle',
composerOpen: true,
uiDesignSpecReference: null,
imageModel: normalizedImageModel,
aspectRatio: '16:9',
imageSize: dimensionDefaults.imageSize,
placeholder: {
x: worldCenter.x - placeholderSize.width / 2,
y: worldCenter.y - placeholderSize.height / 2,
width: placeholderSize.width,
height: placeholderSize.height,
originalWidth: placeholderSize.width,
originalHeight: placeholderSize.height,
},
};
}
export function createEditDialogDraft(
sourceLayer: CanvasLayer,
options: {
imageModel?: string;
aspectRatio?: string;
imageSize?: string;
} = {},
): GenerateDialogState {
const imageModel = normalizeEditorImageModel(
options.imageModel ?? sourceLayer.model,
);
const aspectRatio =
options.aspectRatio ??
inferEditorImageAspectRatio(
sourceLayer.originalWidth,
sourceLayer.originalHeight,
);
const imageSize =
options.imageSize ??
inferEditorImageSizeLabel(
sourceLayer.originalWidth,
sourceLayer.originalHeight,
);
return {
mode: 'edit',
prompt: resolveUserGenerationPromptSnapshot(sourceLayer),
status: 'idle',
composerOpen: true,
sourceLayerId: sourceLayer.id,
imageModel,
aspectRatio,
imageSize,
};
}
export function createQuickEditPanelDraft(
sourceLayer: CanvasLayer,
options: {
imageModel?: string;
aspectRatio?: string;
imageSize?: string;
} = {},
): QuickEditPanelState {
const aspectRatio =
options.aspectRatio ??
inferEditorImageAspectRatio(
sourceLayer.originalWidth,
sourceLayer.originalHeight,
);
const imageSize =
options.imageSize ??
inferEditorImageSizeLabel(
sourceLayer.originalWidth,
sourceLayer.originalHeight,
);
const model = IMAGE_MODEL_GPT_IMAGE_2;
return {
mode: 'quick-edit',
sourceLayerId: sourceLayer.id,
prompt: '',
size: formatImageSizeValue(
sourceLayer.originalWidth,
sourceLayer.originalHeight,
),
aspectRatio,
imageSize,
model,
quickEditReferences: [],
status: 'idle',
};
}
export function createQuickEditGenerationDialogDraft({
sourceLayer,
prompt,
status = 'idle',
references = [],
aspectRatio,
imageSize,
frame,
}: {
sourceLayer: CanvasLayer;
prompt: string;
status?: CanvasGenerationDialogState['status'];
references?: NonNullable<QuickEditPanelState['quickEditReferences']>;
model?: string;
aspectRatio?: string;
imageSize?: string;
frame?: { width: number; height: number };
}): Omit<CanvasGenerationDialogState, 'id'> {
const frameWidth = frame?.width ?? sourceLayer.width;
const frameHeight = frame?.height ?? sourceLayer.height;
return {
mode: 'quick-edit',
prompt,
status,
composerOpen: false,
sourceLayerId: sourceLayer.id,
generationReferences: references.slice(0, 8),
imageModel: IMAGE_MODEL_GPT_IMAGE_2,
aspectRatio,
imageSize,
placeholder: {
x: sourceLayer.x + sourceLayer.width + 32,
y: sourceLayer.y,
width: frameWidth,
height: frameHeight,
originalWidth: frameWidth,
originalHeight: frameHeight,
},
};
}
export function createRedrawPanelDraft(
sourceLayer: CanvasLayer,
options: {
imageModel?: string;
aspectRatio?: string;
imageSize?: string;
} = {},
): QuickEditPanelState {
return {
...createQuickEditPanelDraft(sourceLayer, options),
mode: 'redraw',
model: normalizeEditorImageModel(options.imageModel ?? sourceLayer.model),
prompt: resolveUserGenerationPromptSnapshot(sourceLayer),
};
}
export function createCharacterAnimationPanelDraft(
layer: CanvasLayer,
): CharacterAnimationPanelState | null {
if (layer.assetKind !== 'character') {
return null;
}
return {
sourceLayerId: layer.id,
promptText: '',
resolution: '480p',
ratio: 'same',
frameCount: 32,
durationSeconds: 4,
status: 'idle',
};
}
export function createCharacterAnimationGenerationDialogDraft({
canvasSize,
viewport,
layer,
}: {
canvasSize: CanvasSize;
viewport: CanvasViewport;
layer: CanvasLayer;
}): Omit<CanvasGenerationDialogState, 'id'> | null {
if (layer.assetKind !== 'character') {
return null;
}
const worldCenter = getViewportWorldCenter({ canvasSize, viewport });
return {
mode: 'character-animation',
sourceLayerId: layer.id,
prompt: '',
status: 'idle',
composerOpen: true,
characterAnimationResolution: '480p',
characterAnimationRatio: 'same',
characterAnimationFrameCount: 32,
characterAnimationDurationSeconds: 4,
placeholder: {
x: worldCenter.x - CHARACTER_ANIMATION_FRAME_DISPLAY_SIZE.width / 2,
y: worldCenter.y - CHARACTER_ANIMATION_FRAME_DISPLAY_SIZE.height / 2,
width: CHARACTER_ANIMATION_FRAME_DISPLAY_SIZE.width,
height: CHARACTER_ANIMATION_FRAME_DISPLAY_SIZE.height,
originalWidth: CHARACTER_ANIMATION_FRAME_ORIGINAL_SIZE.width,
originalHeight: CHARACTER_ANIMATION_FRAME_ORIGINAL_SIZE.height,
},
};
}
export function assignCharacterSpecReference(
dialog: GenerateDialogState | null,
layer: CanvasLayer,
): GenerateDialogState | null {
return dialog?.mode === 'character' && isCharacterSpecReferenceLayer(layer)
? {
...resetFailedGenerationDialog(dialog),
characterSpecReference: createCanvasLayerReference(layer),
composerOpen: true,
}
: dialog;
}
export function appendCharacterReference(
dialog: GenerateDialogState | null,
layer: CanvasLayer,
): GenerateDialogState | null {
return dialog?.mode === 'character' &&
getReferenceMediaType(layer) === 'image'
? {
...resetFailedGenerationDialog(dialog),
characterReferences: [
...(dialog.characterReferences ?? []),
createCanvasLayerReference(layer),
],
composerOpen: true,
}
: dialog;
}
export function appendGenerationReference(
dialog: GenerateDialogState | null,
layer: CanvasLayer,
): GenerateDialogState | null {
if (dialog?.mode === 'spec') {
if (getReferenceMediaType(layer) !== 'image') {
return dialog;
}
return {
...resetFailedGenerationDialog(dialog),
specReference: createCanvasLayerReference(layer),
composerOpen: true,
};
}
if (dialog?.mode === 'video' && !isSeedanceVideoModel(dialog.videoModel)) {
return dialog;
}
if (
dialog?.mode === 'generate' ||
dialog?.mode === 'quick-edit' ||
dialog?.mode === 'icon' ||
dialog?.mode === 'ui-design'
) {
if (getReferenceMediaType(layer) !== 'image') {
return dialog;
}
return {
...resetFailedGenerationDialog(dialog),
generationReferences: [
...(dialog.generationReferences ?? []),
createCanvasLayerReference(layer),
],
composerOpen: true,
};
}
return dialog?.mode === 'video'
? {
...resetFailedGenerationDialog(dialog),
generationReferences: appendLimitedSeedanceCanvasReference(
dialog.generationReferences ?? [],
layer,
),
composerOpen: true,
}
: dialog;
}
export function appendPublicationReference(
dialog: GenerateDialogState | null,
layer: CanvasLayer,
): GenerateDialogState | null {
return dialog?.mode === 'publication' &&
getReferenceMediaType(layer) === 'image'
? {
...resetFailedGenerationDialog(dialog),
publicationReferences: [
...(dialog.publicationReferences ?? []),
createCanvasLayerReference(layer),
],
composerOpen: true,
}
: dialog;
}
export function assignIconSpecReference(
dialog: GenerateDialogState | null,
layer: CanvasLayer,
): GenerateDialogState | null {
if (!isIconSpecReferenceLayer(layer)) {
return dialog;
}
return dialog?.mode === 'icon'
? {
...resetFailedGenerationDialog(dialog),
iconSpecReference: createCanvasLayerReference(layer),
composerOpen: true,
}
: dialog;
}
export function assignUiDesignSpecReference(
dialog: GenerateDialogState | null,
layer: CanvasLayer,
): GenerateDialogState | null {
if (!isIconSpecReferenceLayer(layer)) {
return dialog;
}
return dialog?.mode === 'ui-design'
? {
...resetFailedGenerationDialog(dialog),
uiDesignSpecReference: createCanvasLayerReference(layer),
composerOpen: true,
}
: dialog;
}
export function updateSpecFormDialogValue(
dialog: GenerateDialogState | null,
key: keyof SpecFormValues,
value: string,
): GenerateDialogState | null {
if (dialog?.mode !== 'spec') {
return dialog;
}
const specType = dialog.specType ?? 'custom';
return {
...resetFailedGenerationDialog(dialog),
specValues: {
...DEFAULT_SPEC_FORM_VALUES[specType],
...dialog.specValues,
[key]: value,
},
};
}
export function updateIconDescriptionsTextInDialog(
dialog: GenerateDialogState | null,
value: string,
): GenerateDialogState | null {
return dialog?.mode === 'icon'
? {
...resetFailedGenerationDialog(dialog),
prompt: value,
iconDescriptions: parseIconDescriptionsText(value),
}
: dialog;
}
export function appendQuickEditReference(
panel: QuickEditPanelState | null,
layer: CanvasLayer,
): QuickEditPanelState | null {
if (
!panel ||
panel.mode === 'redraw' ||
getReferenceMediaType(layer) !== 'image'
) {
return panel;
}
return {
...resetFailedGenerationPanel(panel),
quickEditReferences: appendLimitedQuickEditReferences(
panel.quickEditReferences,
[createCanvasLayerReference(layer)],
),
};
}
export function updateCharacterAnimationDurationPanel(
panel: CharacterAnimationPanelState | null,
frameCountValue: string,
): CharacterAnimationPanelState | null {
const option = CHARACTER_ANIMATION_DURATION_OPTIONS.find(
(item) => String(item.frameCount) === frameCountValue,
);
if (!option || !panel) {
return panel;
}
return {
...panel,
frameCount: option.frameCount,
durationSeconds: option.durationSeconds,
status: panel.status === 'failed' ? 'idle' : panel.status,
errorMessage: panel.status === 'failed' ? undefined : panel.errorMessage,
};
}
export function hideGeneratedLayerComposerAfterBlur(
dialog: GenerateDialogState | null,
): GenerateDialogState | null {
return (dialog?.mode === 'generate' ||
dialog?.mode === 'spec' ||
dialog?.mode === 'character' ||
dialog?.mode === 'icon' ||
dialog?.mode === 'ui-design' ||
dialog?.mode === 'quick-edit' ||
dialog?.mode === 'character-animation' ||
dialog?.mode === 'video' ||
dialog?.mode === 'publication' ||
dialog?.mode === 'audio-sound-effect' ||
dialog?.mode === 'audio-background-music') &&
dialog.status !== 'generating'
? {
...dialog,
composerOpen: false,
}
: dialog;
}
export function closeGenerateComposerDialog(
dialog: GenerateDialogState | null,
): GenerateDialogState | null {
return dialog?.mode === 'generate' ||
dialog?.mode === 'spec' ||
dialog?.mode === 'character' ||
dialog?.mode === 'icon' ||
dialog?.mode === 'ui-design' ||
dialog?.mode === 'quick-edit' ||
dialog?.mode === 'character-animation' ||
dialog?.mode === 'video' ||
dialog?.mode === 'publication' ||
dialog?.mode === 'audio-sound-effect' ||
dialog?.mode === 'audio-background-music'
? {
...dialog,
composerOpen: false,
}
: dialog;
}