eb608ac6f2
为画布历史记录补充操作类型和内容消失保护 增加撤销成功、恢复成功及阻止提示并在三秒后隐藏 补充拖动、上传、生成和替换等操作的历史语义 保留恢复按钮、Ctrl+Shift+Z 快捷键和双向历史栈 补充撤销与恢复规则的定向测试和说明文档 --------- Co-authored-by: 段舒康 <kdletters@qq.com> Co-authored-by: kdletters <kdletters@qq.com> Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/89 Co-authored-by: oj-afraid-student <1373241747@qq.com> Co-committed-by: oj-afraid-student <1373241747@qq.com>
1668 lines
47 KiB
TypeScript
1668 lines
47 KiB
TypeScript
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,
|
||
EDITOR_IMAGE_MODEL_OPTIONS,
|
||
ICON_DESCRIPTION_LIMIT,
|
||
IMAGE_MODEL_GPT_IMAGE_2,
|
||
inferEditorImageAspectRatio,
|
||
inferEditorImageSizeLabel,
|
||
normalizeEditorImageModel,
|
||
PUBLICATION_FRAME_ORIGINAL_SIZE,
|
||
resizeGenerationPlaceholderToImageSelection,
|
||
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);
|
||
const placeholderSize = resolveEditorImageGenerationPixelSize({
|
||
model: normalizedImageModel,
|
||
aspectRatio: dimensionDefaults.aspectRatio,
|
||
imageSize: dimensionDefaults.imageSize,
|
||
});
|
||
return {
|
||
mode: 'icon',
|
||
prompt: '',
|
||
status: 'idle',
|
||
composerOpen: true,
|
||
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,
|
||
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.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,
|
||
}: 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 ?? 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 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 '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 === '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,
|
||
soundDurationSeconds:
|
||
sourceDialog?.soundDurationSeconds ?? draft.soundDurationSeconds,
|
||
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);
|
||
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 {
|
||
id: `edit-dialog:${sourceLayer.id}`,
|
||
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 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 {
|
||
mode: 'quick-edit',
|
||
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 = [],
|
||
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 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: 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;
|
||
}
|