Files
Genarrative/src/components/image-editor/ImageCanvasGenerationDialogModel.ts
T
menghao e4e3cab559
Project CI / Repository checks (push) Successful in 3m3s
Project CI / Frontend tests (push) Successful in 3m32s
Project CI / Backend tests (push) Successful in 5m45s
Project CI / Native shell tests (push) Successful in 16m17s
修复画布参考图上传删除链路 (#164)
主站规范生成支持多张参考图

修复普通生成参考图删除与提交状态不同步

优化触屏设备删除按钮可达性并补充回归测试

---------

Co-authored-by: kdletters <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/164
Co-authored-by: 孟豪 <mh18530625731@163.com>
Co-committed-by: 孟豪 <mh18530625731@163.com>
2026-08-21 11:55:32 +08:00

2378 lines
69 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 {
SOUND_EFFECT_DURATION_MAX_SECONDS,
SOUND_EFFECT_DURATION_MIN_SECONDS,
} from '../../../packages/shared/src/contracts/editorAudio';
import {
DEFAULT_EDITOR_SCENE_STYLE_PRESET,
resolveEditorSceneStylePresetByLabel,
} from '../../../packages/shared/src/contracts/editorScene';
import { formatImageSizeValue } from './ImageCanvasEditorModel';
import type {
CanvasGenerationDialogState,
CanvasGenerationInputReference,
CanvasLayer,
CanvasViewport,
CharacterAnimationPanelState,
GenerateDialogState,
PublicationMaterialsWorkflowId,
QuickEditPanelState,
SpecFormValues,
SpecGenerationType,
} from './ImageCanvasEditorTypes';
import {
appendLimitedImageReferences,
appendLimitedQuickEditReferences,
AUDIO_FRAME_DISPLAY_SIZE,
AUDIO_FRAME_ORIGINAL_SIZE,
CHARACTER_ANIMATION_DURATION_OPTIONS,
CHARACTER_ANIMATION_FRAME_DISPLAY_SIZE,
CHARACTER_ANIMATION_FRAME_ORIGINAL_SIZE,
createCanvasLayerReference,
decodeCanvasGenerationInputs,
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,
EDITOR_IMAGE_MODEL_OPTIONS,
formatGenerationInputValue,
IMAGE_MODEL_GPT_IMAGE_2,
inferEditorImageAspectRatio,
inferEditorImageSizeLabel,
isNormalizedCanvasGenerationInputs,
normalizeEditorImageModel,
PUBLICATION_FRAME_ORIGINAL_SIZE,
resizeGenerationPlaceholderToImageSelection,
resizeGenerationPlaceholderToVideoSelection,
resolveDialogExtraImageReferenceLimit,
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;
availableLayers?: CanvasLayer[];
};
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,
};
}
export function resolveSpecGenerationReferences(
dialog: GenerateDialogState | null | undefined,
) {
if (!dialog || dialog.mode !== 'spec') {
return [];
}
if (dialog.specType === 'icon') {
return dialog.specReference ? [dialog.specReference] : [];
}
if (dialog.generationReferences?.length) {
return dialog.generationReferences;
}
return dialog.specReference ? [dialog.specReference] : [];
}
export function setSpecGenerationReferences(
dialog: GenerateDialogState,
references: NonNullable<GenerateDialogState['generationReferences']>,
) {
if (dialog.mode !== 'spec') {
return dialog;
}
return dialog.specType === 'icon'
? {
...dialog,
specReference: references[0] ?? null,
generationReferences: [],
}
: {
...dialog,
specReference: null,
generationReferences: references,
};
}
function resetFailedGenerationPanel(panel: QuickEditPanelState) {
return {
...panel,
status: panel.status === 'failed' ? 'idle' : panel.status,
errorMessage: panel.status === 'failed' ? undefined : panel.errorMessage,
};
}
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) =>
formatGenerationInputValue(field.value).includes('游戏UI规范') ||
formatGenerationInputValue(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 = formatGenerationInputValue(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,
style: 'none',
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,
},
};
}
export function createSceneGenerationDialogDraft({
canvasSize,
viewport,
}: {
canvasSize: CanvasSize;
viewport: CanvasViewport;
}): Omit<CanvasGenerationDialogState, 'id'> {
const worldCenter = getViewportWorldCenter({ canvasSize, viewport });
const imageModel = DEFAULT_IMAGE_MODEL;
const aspectRatio = '16:9';
const imageSize = '1K';
const placeholderSize = resolveEditorImageGenerationPixelSize({
model: imageModel,
aspectRatio,
imageSize,
});
return {
mode: 'scene',
prompt: '',
status: 'idle',
composerOpen: true,
generationReferences: [],
sceneStylePreset: DEFAULT_EDITOR_SCENE_STYLE_PRESET,
sceneCustomStyle: '',
imageModel,
aspectRatio,
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 === 'icon';
}
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,
style: 'none',
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);
const placeholderSize = resolveEditorImageGenerationPixelSize({
model: normalizedImageModel,
aspectRatio: dimensionDefaults.aspectRatio,
imageSize: dimensionDefaults.imageSize,
});
return {
mode: 'icon',
prompt: '',
status: 'idle',
composerOpen: true,
style: 'none',
iconSpecReference: null,
generationReferences: [],
iconDescriptions: [],
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 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,
// 与设计稿一致:新建音效默认自动时长,由模型按描述决定长度。
soundDurationMode: 'auto',
soundDurationSeconds: DEFAULT_SOUND_EFFECT_DURATION_SECONDS,
soundLoop: false,
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()));
const field = layer.generationInputs?.fields.find((candidate) =>
normalizedTitles.has(candidate.title.trim().toLowerCase()),
);
return field ? formatGenerationInputValue(field.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
? formatGenerationInputValue(promptField.value).trim()
: undefined;
if (promptValue) {
return promptValue;
}
const structuredLines = fields.flatMap((field) => {
if (!STRUCTURED_USER_INPUT_TITLES.has(field.title.trim().toLowerCase())) {
return [];
}
const value = formatGenerationInputValue(field.value).trim();
return value ? [`${field.title.trim()}${value}`] : [];
});
return structuredLines.join('\n');
}
function resolveAudioRedrawPrompt(sourceLayer: CanvasLayer) {
const soundEffect = sourceLayer.generationInputs?.soundEffect;
if (
sourceLayer.assetKind === 'sound-effect' &&
soundEffect?.schemaVersion === 2 &&
soundEffect.userPrompt.trim()
) {
return soundEffect.userPrompt;
}
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']> {
// 历史 Vidu 素材只提供用户 Prompt 与可兼容的手动时长;重绘始终打开 SFX V2。
return DEFAULT_SOUND_EFFECT_MODEL;
}
function resolveAudioRedrawSoundDuration(sourceLayer: CanvasLayer) {
const soundEffect = sourceLayer.generationInputs?.soundEffect;
if (soundEffect?.schemaVersion === 2) {
if (
soundEffect.durationMode === 'manual' &&
typeof soundEffect.requestedDurationSeconds === 'number' &&
Number.isFinite(soundEffect.requestedDurationSeconds)
) {
return soundEffect.requestedDurationSeconds;
}
return DEFAULT_SOUND_EFFECT_DURATION_SECONDS;
}
const fieldValue = findGenerationInputFieldValue(sourceLayer, [
'时长',
'duration',
]);
const matchedValue = fieldValue?.match(/\d+(?:\.\d+)?/u)?.[0];
const duration = matchedValue ? Number.parseFloat(matchedValue) : null;
if (!duration || !Number.isFinite(duration)) {
return DEFAULT_SOUND_EFFECT_DURATION_SECONDS;
}
return Math.min(
SOUND_EFFECT_DURATION_MAX_SECONDS,
Math.max(SOUND_EFFECT_DURATION_MIN_SECONDS, 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),
soundDurationMode:
sourceLayer.generationInputs?.soundEffect?.schemaVersion === 2 &&
sourceLayer.generationInputs.soundEffect.durationMode === 'auto'
? 'auto'
: 'manual',
soundDurationSeconds: resolveAudioRedrawSoundDuration(sourceLayer),
soundLoop:
sourceLayer.generationInputs?.soundEffect?.schemaVersion === 2
? sourceLayer.generationInputs.soundEffect.loop
: false,
};
}
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 === 'scene') {
return 'scene';
}
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.assetKind === 'video' || sourceLayer.mediaType === 'video') {
return 'video';
}
if (sourceLayer.assetKind === 'sound-effect') {
return 'audio-sound-effect';
}
if (sourceLayer.assetKind === 'background-music') {
return 'audio-background-music';
}
if (sourceLayer.mediaType === 'audio') {
return resolveAudioRedrawMode(sourceLayer);
}
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,
availableLayers,
}: Omit<SourceGenerationDialogDraftContext, 'mode'>): Omit<
CanvasGenerationDialogState,
'id'
> | null {
const draft = createSameSourceGenerationDialogDraft({
sourceLayer,
canvasSize,
viewport,
sourceDialog,
sourceAnimationLayer,
availableLayers,
mode: 'redraw',
});
if (!draft) {
return null;
}
const usesNormalizedInputs = isNormalizedCanvasGenerationInputs(
sourceLayer.generationInputs,
);
return {
...draft,
prompt: usesNormalizedInputs
? draft.prompt
: draft.prompt || sourceLayer.prompt?.trim() || '',
imageModel: usesNormalizedInputs
? draft.imageModel
: (sourceDialog?.imageModel ?? 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 getNormalizedFieldValue(sourceLayer: CanvasLayer, id: string) {
return sourceLayer.generationInputs?.fields.find((field) => field.id === id)
?.value;
}
function getNormalizedString(
sourceLayer: CanvasLayer,
id: string,
fallback = '',
) {
const value = getNormalizedFieldValue(sourceLayer, id);
return typeof value === 'string' ? value : fallback;
}
function getNormalizedNumber(
sourceLayer: CanvasLayer,
id: string,
fallback: number,
) {
const value = getNormalizedFieldValue(sourceLayer, id);
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
function getNormalizedBoolean(
sourceLayer: CanvasLayer,
id: string,
fallback: boolean,
) {
const value = getNormalizedFieldValue(sourceLayer, id);
return typeof value === 'boolean' ? value : fallback;
}
function findAvailableGenerationReferenceLayer(
reference: CanvasGenerationInputReference,
availableLayers: CanvasLayer[] | undefined,
) {
return (availableLayers ?? []).find((candidate) =>
reference.refType === 'project-resource'
? candidate.resourceId === reference.refId
: candidate.sourceAssetId === reference.refId,
);
}
export function getUnavailableNormalizedGenerationInputReferences(
sourceLayer: CanvasLayer,
availableLayers: CanvasLayer[] | undefined,
) {
const decodedInputs = decodeCanvasGenerationInputs(
sourceLayer.generationInputs,
);
if (!decodedInputs.ok) {
return [];
}
return decodedInputs.inputs.references.filter(
(reference) =>
!findAvailableGenerationReferenceLayer(reference, availableLayers),
);
}
function resolveNormalizedReferences(
sourceLayer: CanvasLayer,
id: string,
availableLayers: CanvasLayer[] | undefined,
) {
return (sourceLayer.generationInputs?.references ?? [])
.filter((reference) => reference.id === id)
.flatMap((reference) => {
const layer = findAvailableGenerationReferenceLayer(
reference,
availableLayers,
);
return layer ? [createCanvasLayerReference(layer)] : [];
});
}
function createNormalizedGenerationDialogDraft({
sourceLayer: persistedSourceLayer,
canvasSize,
viewport,
availableLayers,
}: SourceGenerationDialogDraftContext): Omit<
CanvasGenerationDialogState,
'id'
> | null {
const decodedInputs = decodeCanvasGenerationInputs(
persistedSourceLayer.generationInputs,
);
if (!decodedInputs.ok) {
return null;
}
const inputs = decodedInputs.inputs;
const sourceLayer: CanvasLayer = {
...persistedSourceLayer,
generationInputs: inputs,
};
const prompt = getNormalizedString(sourceLayer, 'prompt');
const references = resolveNormalizedReferences(
sourceLayer,
'reference',
availableLayers,
);
if (inputs.action === 'image.generate') {
return placeDraftBesideSourceLayer(
resizeGenerationPlaceholderToImageSelection({
...createGenerateDialogDraft({ canvasSize, viewport }),
prompt,
sourceLayerId: sourceLayer.id,
imageModel: getNormalizedString(
sourceLayer,
'model',
DEFAULT_IMAGE_MODEL,
),
style:
getNormalizedString(sourceLayer, 'style') === 'pixelArt'
? 'pixelArt'
: 'none',
aspectRatio: getNormalizedString(sourceLayer, 'aspectRatio', '1:1'),
imageSize: getNormalizedString(sourceLayer, 'imageSize', '1K'),
generationReferences: references,
}),
sourceLayer,
);
}
if (inputs.action === 'scene.generate') {
return placeDraftBesideSourceLayer(
resizeGenerationPlaceholderToImageSelection({
...createSceneGenerationDialogDraft({ canvasSize, viewport }),
prompt,
sourceLayerId: sourceLayer.id,
sceneStylePreset: getNormalizedString(
sourceLayer,
'stylePreset',
DEFAULT_EDITOR_SCENE_STYLE_PRESET,
) as GenerateDialogState['sceneStylePreset'],
sceneCustomStyle: getNormalizedString(sourceLayer, 'customStyle'),
imageModel: getNormalizedString(
sourceLayer,
'model',
DEFAULT_IMAGE_MODEL,
),
aspectRatio: getNormalizedString(sourceLayer, 'aspectRatio', '16:9'),
imageSize: getNormalizedString(sourceLayer, 'imageSize', '1K'),
generationReferences: references,
}),
sourceLayer,
);
}
if (inputs.action === 'spec.generate') {
const specTypeValue = getNormalizedString(
sourceLayer,
'specType',
'custom',
);
const specType: SpecGenerationType =
specTypeValue === 'ui'
? 'icon'
: specTypeValue === 'character' || specTypeValue === 'icon'
? specTypeValue
: 'custom';
return placeDraftBesideSourceLayer(
{
...createSpecDialogDraft({ canvasSize, viewport, specType }),
sourceLayerId: sourceLayer.id,
specValues: {
playSetting: getNormalizedString(sourceLayer, 'playSetting'),
artStyle: getNormalizedString(sourceLayer, 'artStyle'),
bodyRatio: getNormalizedString(sourceLayer, 'bodyRatio'),
characterView: getNormalizedString(sourceLayer, 'characterView'),
customPrompt: getNormalizedString(sourceLayer, 'customPrompt'),
},
...(specType === 'icon'
? {
specReference:
resolveNormalizedReferences(
sourceLayer,
'reference',
availableLayers,
)[0] ?? null,
}
: {
generationReferences: resolveNormalizedReferences(
sourceLayer,
'reference',
availableLayers,
),
}),
},
sourceLayer,
);
}
if (inputs.action === 'character.generate') {
return placeDraftBesideSourceLayer(
resizeGenerationPlaceholderToImageSelection({
...createCharacterGenerationDialogDraft({
canvasSize,
viewport,
imageModel: getNormalizedString(
sourceLayer,
'model',
DEFAULT_IMAGE_MODEL,
),
}),
prompt,
sourceLayerId: sourceLayer.id,
style:
getNormalizedString(sourceLayer, 'style') === 'pixelArt'
? 'pixelArt'
: 'none',
aspectRatio: getNormalizedString(sourceLayer, 'aspectRatio', '1:1'),
imageSize: getNormalizedString(sourceLayer, 'imageSize', '1K'),
characterSpecReference:
resolveNormalizedReferences(
sourceLayer,
'specReference',
availableLayers,
)[0] ?? null,
characterReferences: references,
}),
sourceLayer,
);
}
if (inputs.action === 'icon.generate') {
return placeDraftBesideSourceLayer(
resizeGenerationPlaceholderToImageSelection({
...createIconGenerationDialogDraft({
canvasSize,
viewport,
imageModel: getNormalizedString(
sourceLayer,
'model',
DEFAULT_IMAGE_MODEL,
),
}),
prompt,
sourceLayerId: sourceLayer.id,
style:
getNormalizedString(sourceLayer, 'style') === 'pixelArt'
? 'pixelArt'
: 'none',
aspectRatio: getNormalizedString(sourceLayer, 'aspectRatio', '1:1'),
imageSize: getNormalizedString(sourceLayer, 'imageSize', '1K'),
iconDescriptions: prompt ? [prompt] : [],
iconSpecReference:
resolveNormalizedReferences(
sourceLayer,
'specReference',
availableLayers,
)[0] ?? null,
generationReferences: references,
}),
sourceLayer,
);
}
if (inputs.action === 'ui-design.generate') {
return placeDraftBesideSourceLayer(
resizeGenerationPlaceholderToImageSelection({
...createUiDesignGenerationDialogDraft({
canvasSize,
viewport,
imageModel: getNormalizedString(
sourceLayer,
'model',
IMAGE_MODEL_GPT_IMAGE_2,
),
}),
prompt,
sourceLayerId: sourceLayer.id,
aspectRatio: getNormalizedString(sourceLayer, 'aspectRatio', '16:9'),
imageSize: getNormalizedString(sourceLayer, 'imageSize', '1K'),
uiDesignSpecReference:
resolveNormalizedReferences(
sourceLayer,
'specReference',
availableLayers,
)[0] ?? null,
generationReferences: references,
}),
sourceLayer,
);
}
if (inputs.action === 'publication.generate') {
const workflowValue = getNormalizedString(
sourceLayer,
'workflowId',
'publication-cover-image',
);
const workflowId: PublicationMaterialsWorkflowId =
workflowValue === 'publication-detail-gallery' ||
workflowValue === 'publication-promo-poster'
? workflowValue
: 'publication-cover-image';
return placeDraftBesideSourceLayer(
{
...createPublicationGenerationDialogDraft({
canvasSize,
viewport,
workflowId,
}),
sourceLayerId: sourceLayer.id,
publicationGameInfo: {
gameName: getNormalizedString(sourceLayer, 'gameName'),
gameCategories: getNormalizedString(sourceLayer, 'gameCategories'),
gameDescription: getNormalizedString(sourceLayer, 'gameDescription'),
},
publicationReferences: references,
},
sourceLayer,
);
}
if (inputs.action === 'video.generate') {
const videoDraft = resizeGenerationPlaceholderToVideoSelection({
...createVideoGenerationDialogDraft({ canvasSize, viewport }),
prompt,
sourceLayerId: sourceLayer.id,
videoModel: getNormalizedString(
sourceLayer,
'model',
DEFAULT_VIDEO_MODEL,
) as GenerateDialogState['videoModel'],
videoAspectRatio: getNormalizedString(
sourceLayer,
'aspectRatio',
DEFAULT_VIDEO_ASPECT_RATIO,
) as GenerateDialogState['videoAspectRatio'],
videoResolution: getNormalizedString(
sourceLayer,
'resolution',
'480p',
) as GenerateDialogState['videoResolution'],
videoDurationSeconds: getNormalizedNumber(
sourceLayer,
'durationSeconds',
DEFAULT_VIDEO_DURATION_SECONDS,
),
videoSound: getNormalizedString(
sourceLayer,
'sound',
DEFAULT_VIDEO_SOUND,
) as GenerateDialogState['videoSound'],
videoWebSearchEnabled: getNormalizedBoolean(
sourceLayer,
'webSearchEnabled',
DEFAULT_VIDEO_WEB_SEARCH_ENABLED,
),
generationReferences: [
...resolveNormalizedReferences(
sourceLayer,
'imageReference',
availableLayers,
),
...resolveNormalizedReferences(
sourceLayer,
'videoReference',
availableLayers,
),
...resolveNormalizedReferences(
sourceLayer,
'audioReference',
availableLayers,
),
],
}) as Omit<CanvasGenerationDialogState, 'id'>;
return placeDraftBesideSourceLayer(videoDraft, sourceLayer);
}
if (
inputs.action === 'audio.sound-effect.generate' ||
inputs.action === 'audio.background-music.generate'
) {
const soundEffect = persistedSourceLayer.generationInputs?.soundEffect;
const restoredDurationMode =
soundEffect?.schemaVersion === 2
? soundEffect.durationMode
: getNormalizedString(sourceLayer, 'durationMode', 'manual');
return placeDraftBesideSourceLayer(
{
...(inputs.action === 'audio.sound-effect.generate'
? createSoundEffectGenerationDialogDraft({ canvasSize, viewport })
: createBackgroundMusicGenerationDialogDraft({
canvasSize,
viewport,
})),
prompt,
sourceLayerId: sourceLayer.id,
soundModel:
inputs.action === 'audio.sound-effect.generate'
? (getNormalizedString(
sourceLayer,
'model',
DEFAULT_SOUND_EFFECT_MODEL,
) as GenerateDialogState['soundModel'])
: undefined,
soundDurationSeconds:
inputs.action === 'audio.sound-effect.generate'
? soundEffect?.schemaVersion === 2 &&
soundEffect.durationMode === 'manual' &&
typeof soundEffect.requestedDurationSeconds === 'number'
? soundEffect.requestedDurationSeconds
: getNormalizedNumber(
sourceLayer,
'durationSeconds',
DEFAULT_SOUND_EFFECT_DURATION_SECONDS,
)
: undefined,
soundDurationMode:
inputs.action === 'audio.sound-effect.generate'
? restoredDurationMode === 'auto'
? 'auto'
: 'manual'
: undefined,
soundLoop:
inputs.action === 'audio.sound-effect.generate'
? soundEffect?.schemaVersion === 2
? soundEffect.loop
: getNormalizedBoolean(sourceLayer, 'loop', false)
: undefined,
},
sourceLayer,
);
}
if (inputs.action === 'character-animation.generate') {
const animationSource = resolveNormalizedReferences(
sourceLayer,
'source',
availableLayers,
)
.map((reference) =>
(availableLayers ?? []).find(
(layer) => createCanvasLayerReference(layer).id === reference.id,
),
)
.find((layer): layer is CanvasLayer => Boolean(layer));
if (!animationSource) {
return null;
}
const draft = createCharacterAnimationGenerationDialogDraft({
canvasSize,
viewport,
layer: animationSource,
});
return draft
? placeDraftBesideSourceLayer(
{
...draft,
prompt,
sourceLayerId: animationSource.id,
characterAnimationResolution: getNormalizedString(
sourceLayer,
'resolution',
'480p',
) as GenerateDialogState['characterAnimationResolution'],
characterAnimationRatio: getNormalizedString(
sourceLayer,
'ratio',
'same',
) as GenerateDialogState['characterAnimationRatio'],
characterAnimationFrameCount: getNormalizedNumber(
sourceLayer,
'frameCount',
32,
) as GenerateDialogState['characterAnimationFrameCount'],
characterAnimationDurationSeconds: getNormalizedNumber(
sourceLayer,
'durationSeconds',
4,
) as GenerateDialogState['characterAnimationDurationSeconds'],
},
sourceLayer,
)
: null;
}
return null;
}
function restoreSharedImageOptions(
draft: Omit<CanvasGenerationDialogState, 'id'>,
sourceLayer: CanvasLayer,
sourceDialog?: CanvasGenerationDialogState | null,
): Omit<CanvasGenerationDialogState, 'id'> {
const requestedModel = normalizeEditorImageModel(
sourceDialog?.imageModel ?? sourceLayer.model ?? draft.imageModel,
);
const imageModel = EDITOR_IMAGE_MODEL_OPTIONS.some(
(option) => option.value === requestedModel,
)
? requestedModel
: DEFAULT_IMAGE_MODEL;
const dimensionOptions =
EDITOR_IMAGE_DIMENSION_OPTIONS[
imageModel as keyof typeof EDITOR_IMAGE_DIMENSION_OPTIONS
] ?? EDITOR_IMAGE_DIMENSION_OPTIONS[DEFAULT_IMAGE_MODEL];
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 preferredAspectRatio = sourceDialog?.aspectRatio ?? inferredAspectRatio;
const preferredImageSize = sourceDialog?.imageSize ?? inferredImageSize;
return resizeGenerationPlaceholderToImageSelection({
...draft,
imageModel,
aspectRatio: supportedAspectRatios.includes(preferredAspectRatio)
? preferredAspectRatio
: draft.aspectRatio && supportedAspectRatios.includes(draft.aspectRatio)
? draft.aspectRatio
: (dimensionOptions.aspectRatios[0] ?? '1:1'),
imageSize: supportedImageSizes.includes(preferredImageSize)
? preferredImageSize
: draft.imageSize && supportedImageSizes.includes(draft.imageSize)
? draft.imageSize
: (dimensionOptions.imageSizes.find((size) => size === '1K') ??
dimensionOptions.imageSizes[0] ??
'1K'),
});
}
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 'icon';
}
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 prompt =
findGenerationInputFieldValue(sourceLayer, ['素材描述']) ??
(sourceDialog?.prompt || sourceDialog?.iconDescriptions?.join('\n')) ??
'';
const normalizedPrompt = prompt.trim();
return {
...draft,
prompt,
iconDescriptions: normalizedPrompt
? [normalizedPrompt]
: (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,
availableLayers,
}: SourceGenerationDialogDraftContext): Omit<
CanvasGenerationDialogState,
'id'
> | null {
if (sourceLayer.generationInputsHydrationState === 'invalid-versioned') {
return null;
}
const normalizedDraft = createNormalizedGenerationDialogDraft({
sourceLayer,
canvasSize,
viewport,
sourceDialog,
mode,
sourceAnimationLayer,
availableLayers,
});
if (normalizedDraft) {
return normalizedDraft;
}
if (isNormalizedCanvasGenerationInputs(sourceLayer.generationInputs)) {
return 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 === 'scene') {
const fields = getGenerationInputFieldValues(sourceLayer);
const styleLabel = fields.get('视觉风格');
const sceneStylePreset =
sourceDialog?.sceneStylePreset ??
resolveEditorSceneStylePresetByLabel(styleLabel);
return placeDraftBesideSourceLayer(
restoreSharedImageOptions(
{
...createSceneGenerationDialogDraft({ canvasSize, viewport }),
prompt,
sourceLayerId: sourceLayer.id,
sceneStylePreset,
sceneCustomStyle:
fields.get('自定义画风') ?? sourceDialog?.sceneCustomStyle ?? '',
generationReferences:
sourceDialog?.mode === 'scene'
? (sourceDialog.generationReferences ?? [])
: [],
},
sourceLayer,
sourceDialog,
),
sourceLayer,
);
}
if (sourceMode === 'video') {
const draft = createVideoRedrawGenerationDialogDraft(sourceLayer);
if (!draft) {
return null;
}
return {
...draft,
prompt,
videoModel: sourceDialog?.videoModel ?? draft.videoModel,
videoAspectRatio:
sourceDialog?.videoAspectRatio ?? draft.videoAspectRatio,
videoResolution: sourceDialog?.videoResolution ?? draft.videoResolution,
videoDurationSeconds:
sourceDialog?.videoDurationSeconds ?? draft.videoDurationSeconds,
videoMode: sourceDialog?.videoMode ?? draft.videoMode,
videoSound: sourceDialog?.videoSound ?? draft.videoSound,
videoWebSearchEnabled:
sourceDialog?.videoWebSearchEnabled ?? draft.videoWebSearchEnabled,
generationReferences:
sourceDialog?.mode === 'video'
? (sourceDialog.generationReferences ?? draft.generationReferences)
: draft.generationReferences,
};
}
if (
sourceMode === 'audio-sound-effect' ||
sourceMode === 'audio-background-music'
) {
const draft = createAudioRedrawGenerationDialogDraft(sourceLayer);
if (!draft) {
return null;
}
return {
...draft,
prompt,
soundModel: sourceDialog?.soundModel ?? draft.soundModel,
soundDurationMode:
sourceDialog?.soundDurationMode ?? draft.soundDurationMode,
soundDurationSeconds:
sourceDialog?.soundDurationSeconds ?? draft.soundDurationSeconds,
soundLoop: sourceDialog?.soundLoop ?? draft.soundLoop,
makeInstrumental:
sourceDialog?.makeInstrumental ?? draft.makeInstrumental,
};
}
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);
const sourceReferences =
sourceDialog?.mode === 'spec'
? resolveSpecGenerationReferences(sourceDialog)
: [];
return placeDraftBesideSourceLayer(
restoreSpecValuesFromLayer(
{
...createSpecDialogDraft({ canvasSize, viewport, specType }),
prompt,
sourceLayerId: sourceLayer.id,
...(specType === 'icon'
? {
specReference: sourceReferences[0] ?? null,
generationReferences: [],
}
: {
specReference: null,
generationReferences: sourceReferences,
}),
},
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 decodedInputs = decodeCanvasGenerationInputs(
sourceLayer.generationInputs,
);
const normalizedSourceLayer = decodedInputs.ok
? { ...sourceLayer, generationInputs: decodedInputs.inputs }
: sourceLayer;
const persistedImageModel = decodedInputs.ok
? getNormalizedString(normalizedSourceLayer, 'model') || undefined
: undefined;
const persistedAspectRatio = decodedInputs.ok
? getNormalizedString(normalizedSourceLayer, 'aspectRatio') || undefined
: undefined;
const persistedImageSize = decodedInputs.ok
? getNormalizedString(normalizedSourceLayer, 'imageSize') || undefined
: undefined;
const resolvedOptions = createQuickEditPanelDraft(sourceLayer, {
imageModel:
options.imageModel ??
persistedImageModel ??
sourceLayer.model ??
undefined,
aspectRatio: options.aspectRatio ?? persistedAspectRatio,
imageSize: options.imageSize ?? persistedImageSize,
});
return {
id: `edit-dialog:${sourceLayer.id}`,
mode: 'edit',
prompt: resolveUserGenerationPromptSnapshot(sourceLayer),
status: 'idle',
composerOpen: true,
sourceLayerId: sourceLayer.id,
imageModel: resolvedOptions.model,
aspectRatio: resolvedOptions.aspectRatio,
imageSize: resolvedOptions.imageSize,
};
}
export function createQuickEditPanelDraft(
sourceLayer: CanvasLayer,
options: {
imageModel?: string;
aspectRatio?: string;
imageSize?: string;
} = {},
): QuickEditPanelState {
const normalizedModel = normalizeEditorImageModel(
options.imageModel ?? sourceLayer.model,
);
const model = EDITOR_IMAGE_MODEL_OPTIONS.some(
(option) => option.value === normalizedModel,
)
? normalizedModel
: DEFAULT_IMAGE_MODEL;
const dimensionOptions =
EDITOR_IMAGE_DIMENSION_OPTIONS[
model as keyof typeof EDITOR_IMAGE_DIMENSION_OPTIONS
] ?? EDITOR_IMAGE_DIMENSION_OPTIONS[DEFAULT_IMAGE_MODEL];
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(options.aspectRatio ?? '')
? options.aspectRatio
: supportedAspectRatios.includes(inferredAspectRatio)
? inferredAspectRatio
: (dimensionOptions.aspectRatios[0] ?? '1:1');
const imageSize = supportedImageSizes.includes(options.imageSize ?? '')
? options.imageSize
: supportedImageSizes.includes(inferredImageSize)
? inferredImageSize
: (dimensionOptions.imageSizes.find((size) => size === '1K') ??
dimensionOptions.imageSizes[0] ??
'1K');
return {
sourceLayerId: sourceLayer.id,
prompt: '',
size: formatImageSizeValue(
sourceLayer.originalWidth,
sourceLayer.originalHeight,
),
aspectRatio,
imageSize,
model,
quickEditReferences: [],
status: 'idle',
};
}
export function createQuickEditGenerationDialogDraft({
sourceLayer,
prompt,
assetLabel,
status = 'idle',
references = [],
model = IMAGE_MODEL_GPT_IMAGE_2,
aspectRatio,
imageSize,
frame,
}: {
sourceLayer: CanvasLayer;
prompt: string;
assetLabel?: string;
status?: CanvasGenerationDialogState['status'];
references?: NonNullable<QuickEditPanelState['quickEditReferences']>;
model?: string;
aspectRatio?: string;
imageSize?: string;
frame?: { width: number; height: number };
}): Omit<CanvasGenerationDialogState, 'id'> {
const resolvedOptions = createQuickEditPanelDraft(sourceLayer, {
imageModel: model,
aspectRatio,
imageSize,
});
const frameWidth = frame?.width ?? sourceLayer.width;
const frameHeight = frame?.height ?? sourceLayer.height;
return {
mode: 'quick-edit',
prompt,
assetLabel,
status,
composerOpen: false,
sourceLayerId: sourceLayer.id,
generationReferences: references.slice(0, 8),
imageModel: resolvedOptions.model,
aspectRatio: resolvedOptions.aspectRatio,
imageSize: resolvedOptions.imageSize,
placeholder: {
x: sourceLayer.x + sourceLayer.width + 32,
y: sourceLayer.y,
width: frameWidth,
height: frameHeight,
originalWidth: frameWidth,
originalHeight: frameHeight,
},
};
}
export function createLegacyImageRedrawGenerationDialogDraft({
sourceLayer,
canvasSize,
viewport,
sourceDialog,
}: {
sourceLayer: CanvasLayer;
canvasSize: CanvasSize;
viewport: CanvasViewport;
sourceDialog?: CanvasGenerationDialogState | null;
}): Omit<CanvasGenerationDialogState, 'id'> {
return placeDraftBesideSourceLayer(
restoreSharedImageOptions(
{
...createGenerateDialogDraft({ canvasSize, viewport }),
prompt: resolveUserGenerationPromptSnapshot(sourceLayer),
sourceLayerId: sourceLayer.id,
},
sourceLayer,
sourceDialog,
),
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),
characterReferences: dialog.characterReferences,
composerOpen: true,
}
: dialog;
}
export function appendCharacterReference(
dialog: GenerateDialogState | null,
layer: CanvasLayer,
): GenerateDialogState | null {
return dialog?.mode === 'character' &&
getReferenceMediaType(layer) === 'image'
? {
...resetFailedGenerationDialog(dialog),
characterReferences: appendLimitedImageReferences(
dialog.characterReferences,
[createCanvasLayerReference(layer)],
resolveDialogExtraImageReferenceLimit(dialog),
),
composerOpen: true,
}
: dialog;
}
export function appendGenerationReference(
dialog: GenerateDialogState | null,
layer: CanvasLayer,
): GenerateDialogState | null {
if (dialog?.mode === 'spec') {
if (getReferenceMediaType(layer) !== 'image') {
return dialog;
}
const resetDialog = {
...resetFailedGenerationDialog(dialog),
composerOpen: true,
};
return setSpecGenerationReferences(
resetDialog,
dialog.specType === 'icon'
? [createCanvasLayerReference(layer)]
: appendLimitedImageReferences(
resolveSpecGenerationReferences(dialog),
[createCanvasLayerReference(layer)],
resolveDialogExtraImageReferenceLimit(dialog),
),
);
}
if (dialog?.mode === 'video' && !isSeedanceVideoModel(dialog.videoModel)) {
return dialog;
}
if (
dialog?.mode === 'generate' ||
dialog?.mode === 'scene' ||
dialog?.mode === 'quick-edit' ||
dialog?.mode === 'icon' ||
dialog?.mode === 'ui-design'
) {
if (getReferenceMediaType(layer) !== 'image') {
return dialog;
}
return {
...resetFailedGenerationDialog(dialog),
generationReferences: appendLimitedImageReferences(
dialog.generationReferences,
[createCanvasLayerReference(layer)],
resolveDialogExtraImageReferenceLimit(dialog),
),
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: appendLimitedImageReferences(
dialog.publicationReferences,
[createCanvasLayerReference(layer)],
resolveDialogExtraImageReferenceLimit(dialog),
),
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),
generationReferences: appendLimitedImageReferences(
[],
dialog.generationReferences ?? [],
resolveDialogExtraImageReferenceLimit(dialog),
),
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),
generationReferences: dialog.generationReferences,
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: [],
}
: dialog;
}
export function appendQuickEditReference(
panel: QuickEditPanelState | null,
layer: CanvasLayer,
): QuickEditPanelState | null {
if (!panel || 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 === 'scene' ||
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 === 'scene' ||
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;
}