Files
Genarrative/src/components/image-editor/ImageCanvasGenerationComposerView.tsx
T
agent 3508957eda 补齐 AGC V3 C9 参考图选择弹窗与提交快照
- 新增 ImageCanvasProjectAssetPickerDialog:独立弹窗,网格卡片、多选、筛选+搜索、清空/确认/取消,筛选状态自持

- 新增 projectAssetReferencePickerModel:素材分类与筛选判据、素材 id 快照引用构造、弹窗引用前缀判定

- 生成 composer 参考图来源菜单新增「从项目素材中选择」,由宿主决定是否出现,不改变原有从画布选择与上传两条路径

- 新增 replaceProjectAssetPickerReferences:确认时整组替换素材库来源的参考图,保留画布点选与上传的参考图

- 素材 id 经 sourceAssetId 走既有 createGenerationInputReference 落进 generationInputs.references,不新增后端接口

- 新增 13 条测试覆盖弹窗交互、筛选叠加、快照落库与整组替换语义
2026-09-10 15:13:40 +08:00

1850 lines
71 KiB
TypeScript

import {
Check,
ChevronDown,
Clock,
Cpu,
Film,
ImageIcon,
Music,
Repeat,
Sparkles,
Undo2,
WandSparkles,
} from 'lucide-react';
import {
type CSSProperties,
type Dispatch,
type ReactNode,
type RefObject,
type SetStateAction,
useCallback,
useEffect,
useId,
useRef,
useState,
} from 'react';
import {
SOUND_EFFECT_DURATION_MAX_SECONDS,
SOUND_EFFECT_DURATION_MIN_SECONDS,
} from '../../../packages/shared/src/contracts/editorAudio';
import { AutoGrowTextArea } from '../common/AutoGrowTextArea';
import { PlatformActionButton } from '../common/PlatformActionButton';
import {
PlatformFloatingMenu,
PlatformFloatingMenuItem,
} from '../common/PlatformFloatingMenu';
import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { ImageCanvasBackgroundMusicPresetMarquee } from './ImageCanvasBackgroundMusicPresetMarquee';
import {
appendBackgroundMusicPromptPreset,
BACKGROUND_MUSIC_PROMPT_PRESETS,
type BackgroundMusicPromptPreset,
} from './ImageCanvasBackgroundMusicPresetModel';
import {
BACKGROUND_MUSIC_PROMPT_MAX_CODE_POINTS,
canCompleteBackgroundMusicPrompt,
canGenerateBackgroundMusicFromPrompt,
canSimplifyBackgroundMusicPrompt,
countPromptCodePoints,
toBackgroundMusicGenerationDialog,
} from './ImageCanvasBackgroundMusicPromptModel';
import { ImageCanvasBasicGenerationComposerView } from './ImageCanvasBasicGenerationComposerView';
import { ImageCanvasCharacterAnimationPanelView } from './ImageCanvasCharacterAnimationPanelView';
import { ImageCanvasCharacterGenerationComposerView } from './ImageCanvasCharacterGenerationComposerView';
import { ImageCanvasCropExpandPanelView } from './ImageCanvasCropExpandPanelView';
import { ImageCanvasEditGenerationModalView } from './ImageCanvasEditGenerationModalView';
import { ImageCanvasEditorPortal } from './ImageCanvasEditorPortal';
import type {
CanvasGenerationDialogState,
CanvasLayer,
CharacterAnimationPanelState,
CropExpandPanelState,
GenerateDialogState,
QuickEditPanelState,
SpecFormValues,
SpecGenerationType,
UploadTarget,
} from './ImageCanvasEditorTypes';
import {
calculateEditorBackgroundMusicPrice,
calculateEditorSoundEffectPrice,
calculateEditorVideoPrice,
DEFAULT_BACKGROUND_MUSIC_MODEL,
DEFAULT_SOUND_EFFECT_DURATION_SECONDS,
DEFAULT_SOUND_EFFECT_MODEL,
DEFAULT_VIDEO_ASPECT_RATIO,
DEFAULT_VIDEO_DURATION_SECONDS,
DEFAULT_VIDEO_SOUND,
EDITOR_SOUND_EFFECT_MODEL_OPTIONS,
EDITOR_VIDEO_ASPECT_RATIO_OPTIONS,
EDITOR_VIDEO_MODEL_OPTIONS,
EDITOR_VIDEO_RESOLUTION_OPTIONS,
resizeGenerationPlaceholderToVideoSelection,
SPEC_TYPE_LABEL,
VIDEO_MODEL_SEEDANCE_2_FAST,
} from './ImageCanvasGenerationModel';
import { ImageCanvasIconSpritesheetComposerView } from './ImageCanvasIconSpritesheetComposerView';
import { ImageCanvasPublicationMaterialsDemoPanelView } from './ImageCanvasPublicationMaterialsDemoPanelView';
import { getPublicationMaterialsWorkflow } from './ImageCanvasPublicationMaterialsModel';
import { ImageCanvasQuickEditPanelView } from './ImageCanvasQuickEditPanelView';
import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
import { ImageCanvasSceneStyleControl } from './ImageCanvasSceneStyleControl';
import { ImageCanvasSoundEffectPresetMarquee } from './ImageCanvasSoundEffectPresetMarquee';
import {
appendSoundEffectPromptPreset,
SOUND_EFFECT_PROMPT_PRESETS,
type SoundEffectPromptPreset,
} from './ImageCanvasSoundEffectPresetModel';
import {
canGenerateSoundEffectFromPrompt,
canOptimizeSoundEffectPrompt,
countSoundEffectPromptCodePoints,
SOUND_EFFECT_PROMPT_MAX_CODE_POINTS,
toSoundEffectGenerationDialog,
} from './ImageCanvasSoundEffectPromptModel';
import { ImageCanvasSpecGenerationPanelView } from './ImageCanvasSpecGenerationPanelView';
import type { BackgroundMusicPromptAssistComposerController } from './useImageCanvasBackgroundMusicPromptAssist';
import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss';
import type { SoundEffectPromptAssistComposerController } from './useImageCanvasSoundEffectPromptAssist';
type ImageCanvasGenerationComposerViewProps = {
specToolWrapRef: RefObject<HTMLSpanElement | null>;
publicationReferenceButtonRef: RefObject<HTMLButtonElement | null>;
characterSpecButtonRef: RefObject<HTMLButtonElement | null>;
characterReferenceButtonRef: RefObject<HTMLButtonElement | null>;
generationReferenceButtonRef: RefObject<HTMLButtonElement | null>;
iconSpecButtonRef: RefObject<HTMLButtonElement | null>;
isSpecMenuOpen: boolean;
isGenerationReferenceMenuOpen: boolean;
isPublicationReferenceMenuOpen: boolean;
isCharacterSpecMenuOpen: boolean;
isCharacterReferenceMenuOpen: boolean;
isIconSpecMenuOpen: boolean;
isUiDesignSpecMenuOpen: boolean;
isPickingGenerationReferenceFromCanvas: boolean;
isPickingQuickEditReferenceFromCanvas: boolean;
isPickingPublicationReferenceFromCanvas: boolean;
isPickingCharacterSpecFromCanvas: boolean;
isPickingCharacterReferenceFromCanvas: boolean;
isPickingIconSpecFromCanvas: boolean;
isPickingUiDesignSpecFromCanvas: boolean;
hasPendingImageReferenceUploads?: boolean;
generateDialog: GenerateDialogState | null;
backgroundMusicPromptAssist?: BackgroundMusicPromptAssistComposerController;
soundEffectPromptAssist?: SoundEffectPromptAssistComposerController;
updateCanvasGenerationDialogById: (
dialogId: string,
updater: (
dialog: CanvasGenerationDialogState,
) => CanvasGenerationDialogState | null,
) => void;
generationComposerStyle: CSSProperties | null;
iconComposerStyle: CSSProperties | null;
quickEditPanel: QuickEditPanelState | null;
quickEditSourceLayer: CanvasLayer | null;
quickEditPanelStyle: CSSProperties | null;
cropExpandPanel: CropExpandPanelState | null;
cropExpandSourceLayer: CanvasLayer | null;
cropExpandPanelStyle: CSSProperties | null;
characterAnimationPanel: CharacterAnimationPanelState | null;
characterAnimationSourceLayer: CanvasLayer | null;
characterAnimationPanelStyle: CSSProperties | null;
characterAnimationPrice: number;
setGenerateDialog: Dispatch<SetStateAction<GenerateDialogState | null>>;
setQuickEditPanel: Dispatch<SetStateAction<QuickEditPanelState | null>>;
setCropExpandPanel: Dispatch<SetStateAction<CropExpandPanelState | null>>;
setCharacterAnimationPanel: Dispatch<
SetStateAction<CharacterAnimationPanelState | null>
>;
setIsGenerationReferenceMenuOpen: Dispatch<SetStateAction<boolean>>;
setIsPublicationReferenceMenuOpen: Dispatch<SetStateAction<boolean>>;
setIsCharacterSpecMenuOpen: Dispatch<SetStateAction<boolean>>;
setIsCharacterReferenceMenuOpen: Dispatch<SetStateAction<boolean>>;
setIsIconSpecMenuOpen: Dispatch<SetStateAction<boolean>>;
setIsUiDesignSpecMenuOpen: Dispatch<SetStateAction<boolean>>;
setIsPickingGenerationReferenceFromCanvas: Dispatch<SetStateAction<boolean>>;
setIsPickingQuickEditReferenceFromCanvas: Dispatch<SetStateAction<boolean>>;
setIsPickingPublicationReferenceFromCanvas: Dispatch<SetStateAction<boolean>>;
setIsPickingCharacterSpecFromCanvas: Dispatch<SetStateAction<boolean>>;
setIsPickingCharacterReferenceFromCanvas: Dispatch<SetStateAction<boolean>>;
setIsPickingIconSpecFromCanvas: Dispatch<SetStateAction<boolean>>;
setIsPickingUiDesignSpecFromCanvas: Dispatch<SetStateAction<boolean>>;
onOpenSpecDialog: (specType: SpecGenerationType) => void;
onRequestUpload: (target: UploadTarget) => void;
/** 参考图来源菜单里的「从项目素材中选择」;不传则不显示该项。 */
onRequestProjectAssetPicker?: () => void;
onSubmitImageGeneration: (dialog: GenerateDialogState) => void;
onSubmitIconSpritesheetGeneration: (dialog: GenerateDialogState) => void;
onSubmitQuickEdit: () => void;
onSubmitCropExpand: () => void;
onSubmitCharacterAnimation: () => void;
onRetryPerfectPixelOperation?: (dialogId: string) => void;
onUpdateSpecFormValue: (key: keyof SpecFormValues, value: string) => void;
onUpdateIconDescriptionText: (value: string) => void;
onUpdateCharacterAnimationDuration: (frameCountValue: string) => void;
onRememberImageModel: (model: string) => void;
onSpecMenuPointerEnter?: () => void;
onSpecMenuPointerLeave?: () => void;
};
function PerfectPixelOperationPanel({
dialog,
style,
onRetry,
}: {
dialog: GenerateDialogState;
style: CSSProperties;
onRetry?: (dialogId: string) => void;
}) {
const isPending = dialog.status === 'pending-confirmation';
const isPreparedFailure =
dialog.status === 'failed' && Boolean(dialog.perfectPixelOperation);
const isGenerating = dialog.status === 'generating';
const isInvalid = dialog.perfectPixelOperationInvalid === true;
const message = isInvalid
? (dialog.errorMessage ?? '完美像素操作快照无效,禁止自动重试。')
: isPending
? (dialog.errorMessage ?? '结果尚未确认,系统不会自动重复提交。')
: isGenerating
? '完美像素处理中'
: dialog.status === 'failed'
? (dialog.errorMessage ?? '完美像素处理失败')
: '完美像素处理已完成';
return (
<section
className="image-canvas-editor__generation-composer image-canvas-editor__generation-composer--perfect-pixel"
style={style}
role="dialog"
aria-label="完美像素操作"
onPointerDown={(event) => event.stopPropagation()}
>
<strong>完美像素</strong>
<PlatformStatusMessage
tone={isInvalid || dialog.status === 'failed' ? 'error' : 'info'}
surface="platform"
size="xs"
className="image-canvas-editor__generate-status"
role={isInvalid || dialog.status === 'failed' ? 'alert' : 'status'}
>
{message}
</PlatformStatusMessage>
{!isInvalid &&
(isPending || isPreparedFailure) &&
dialog.id &&
dialog.perfectPixelOperation ? (
<PlatformActionButton
type="button"
size="sm"
shape="pill"
className="image-canvas-editor__generation-submit"
disabled={!onRetry}
onClick={() => onRetry?.(dialog.id!)}
>
重试同一完美像素操作
</PlatformActionButton>
) : null}
</section>
);
}
function buildPortalMenuStyle(
anchor: HTMLElement | null,
placement: 'above' | 'below',
): CSSProperties {
const rect = anchor?.getBoundingClientRect();
if (!rect) {
return {
position: 'fixed',
left: 0,
top: 0,
right: 'auto',
bottom: 'auto',
zIndex: 70,
};
}
return {
position: 'fixed',
left: Math.round(rect.left),
top:
placement === 'above'
? Math.round(rect.top)
: Math.round(rect.bottom + 8),
right: 'auto',
bottom: 'auto',
zIndex: 70,
transform:
placement === 'above' ? 'translateY(calc(-100% - 0.45rem))' : undefined,
};
}
function renderEditorPortal(node: ReactNode) {
return <ImageCanvasEditorPortal>{node}</ImageCanvasEditorPortal>;
}
function resetFailedVideoDialogStatus(dialog: GenerateDialogState) {
return {
...dialog,
status: dialog.status === 'failed' ? 'idle' : dialog.status,
errorMessage: dialog.status === 'failed' ? undefined : dialog.errorMessage,
};
}
function isSeedanceVideoModel(model: string | undefined) {
return model === 'seedance2.0' || model === 'seedance2.0-fast';
}
function VideoReferenceChip({
reference,
index,
onRemove,
}: {
reference: {
id: string;
label: string;
src: string;
mediaType?: string;
objectKey?: string | null;
};
index: number;
onRemove: () => void;
}) {
const mediaType = reference.mediaType ?? 'image';
const labelPrefix =
mediaType === 'video'
? '参考视频'
: mediaType === 'audio'
? '参考音频'
: '参考图';
const label = reference.label || `${labelPrefix}${index + 1}`;
const icon =
mediaType === 'video' ? (
<Film className="h-4 w-4" aria-hidden="true" />
) : mediaType === 'audio' ? (
<Music className="h-4 w-4" aria-hidden="true" />
) : (
<ImageIcon className="h-4 w-4" aria-hidden="true" />
);
return (
<ImageCanvasReferenceSlot
tone={mediaType === 'audio' ? 'audio' : 'video'}
icon={icon}
imageSrc={mediaType === 'image' ? reference.src : undefined}
objectKey={mediaType === 'image' ? reference.objectKey : undefined}
label={label}
ariaLabel={label}
title={reference.label}
onRemove={onRemove}
removeLabel={`删除${label}`}
/>
);
}
function VideoOptionChoice({
children,
selected,
className,
ariaLabel,
disabled,
onClick,
}: {
children: ReactNode;
selected: boolean;
className?: string;
ariaLabel?: string;
disabled?: boolean;
onClick?: () => void;
}) {
return (
<button
type="button"
className={['image-canvas-editor__option-popover-choice', className]
.filter(Boolean)
.join(' ')}
aria-label={ariaLabel}
aria-pressed={selected}
disabled={disabled}
onClick={onClick}
>
{children}
</button>
);
}
function ImageCanvasVideoGenerationComposerView({
dialog,
style,
generationReferenceButtonRef,
isGenerationReferenceMenuOpen,
setGenerateDialog,
setIsGenerationReferenceMenuOpen,
setIsPickingGenerationReferenceFromCanvas,
renderEditorPortal,
buildPortalMenuStyle,
onRequestUpload,
onSubmit,
}: {
dialog: GenerateDialogState;
style: CSSProperties;
generationReferenceButtonRef: RefObject<HTMLButtonElement | null>;
isGenerationReferenceMenuOpen: boolean;
setGenerateDialog: Dispatch<SetStateAction<GenerateDialogState | null>>;
setIsGenerationReferenceMenuOpen: Dispatch<SetStateAction<boolean>>;
setIsPickingGenerationReferenceFromCanvas: Dispatch<SetStateAction<boolean>>;
renderEditorPortal: (node: ReactNode) => ReactNode;
buildPortalMenuStyle: (
anchor: HTMLElement | null,
placement: 'above' | 'below',
) => CSSProperties;
onRequestUpload: (target: UploadTarget) => void;
onSubmit: (dialog: GenerateDialogState) => void;
}) {
const [openPanel, setOpenPanel] = useState<'params' | 'model' | null>(null);
const paramsButtonRef = useRef<HTMLButtonElement | null>(null);
const modelButtonRef = useRef<HTMLButtonElement | null>(null);
const normalizeVideoDuration = (value: number) =>
Math.min(15, Math.max(4, Math.round(value)));
const resolution = dialog.videoResolution ?? '480p';
const durationSeconds = normalizeVideoDuration(
dialog.videoDurationSeconds ?? DEFAULT_VIDEO_DURATION_SECONDS,
);
const soundMode = dialog.videoSound ?? DEFAULT_VIDEO_SOUND;
const aspectRatio = dialog.videoAspectRatio ?? DEFAULT_VIDEO_ASPECT_RATIO;
const currentModel =
EDITOR_VIDEO_MODEL_OPTIONS.find(
(item) => item.value === dialog.videoModel,
) ?? EDITOR_VIDEO_MODEL_OPTIONS[0];
const supportsReferences = isSeedanceVideoModel(currentModel.value);
const price = calculateEditorVideoPrice(
currentModel.value,
resolution,
durationSeconds,
);
const isGenerating = dialog.status === 'generating';
const closeVideoFloatingPanels = useCallback(() => {
setOpenPanel(null);
setIsGenerationReferenceMenuOpen(false);
}, [setIsGenerationReferenceMenuOpen]);
useImageCanvasFloatingOptionDismiss({
isOpen: openPanel !== null || isGenerationReferenceMenuOpen,
boundaryRefs: [
paramsButtonRef,
modelButtonRef,
generationReferenceButtonRef,
],
onDismiss: closeVideoFloatingPanels,
});
const updateVideoDialog = (patch: Partial<GenerateDialogState>) => {
setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'video'
? resizeGenerationPlaceholderToVideoSelection({
...resetFailedVideoDialogStatus(currentDialog),
...patch,
})
: currentDialog,
);
};
const updateVideoModel = (model: GenerateDialogState['videoModel']) => {
updateVideoDialog({
videoModel: model,
videoResolution:
model === VIDEO_MODEL_SEEDANCE_2_FAST &&
dialog.videoResolution === '1080p'
? '720p'
: dialog.videoResolution,
...(isSeedanceVideoModel(model) ? {} : { generationReferences: [] }),
});
};
return (
<>
<div
className="image-canvas-editor__generation-composer image-canvas-editor__generation-composer--image image-canvas-editor__generation-composer--video"
style={style}
role="dialog"
aria-label="生成视频"
onPointerDown={(event) => event.stopPropagation()}
>
{supportsReferences ? (
<div className="image-canvas-editor__reference-strip">
{(dialog.generationReferences ?? []).map((reference, index) => (
<VideoReferenceChip
key={reference.id}
reference={reference}
index={index}
onRemove={() =>
updateVideoDialog({
generationReferences: (
dialog.generationReferences ?? []
).filter((item) => item.id !== reference.id),
})
}
/>
))}
<ImageCanvasReferenceSlot
buttonRef={generationReferenceButtonRef}
tone="video"
icon={<ImageIcon className="h-4 w-4" aria-hidden="true" />}
label="参考"
ariaLabel="添加视频参考素材"
disabled={isGenerating}
isAdd
onClick={() => setIsGenerationReferenceMenuOpen((open) => !open)}
/>
</div>
) : null}
<AutoGrowTextArea
aria-label="视频描述"
value={dialog.prompt}
disabled={isGenerating}
placeholder="你希望生成什么视频?"
className="image-canvas-editor__generation-prompt"
onValueChange={(value) => updateVideoDialog({ prompt: value })}
/>
<div className="image-canvas-editor__generation-composer-footer">
<div className="image-canvas-editor__option-popover-anchor image-canvas-editor__option-popover-anchor--dimensions">
<PlatformInlineOptionButton
ref={paramsButtonRef}
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--dimensions"
aria-label={`视频参数 ${aspectRatio} · ${durationSeconds}秒 · ${resolution}`}
aria-expanded={openPanel === 'params'}
disabled={isGenerating}
trailingIcon={<ChevronDown className="h-3 w-3" />}
onClick={() =>
setOpenPanel((currentPanel) =>
currentPanel === 'params' ? null : 'params',
)
}
>
{aspectRatio} · {durationSeconds} · {resolution} ·{' '}
{soundMode === 'off' ? '静音' : '有声'}
</PlatformInlineOptionButton>
{openPanel === 'params'
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__option-popover image-canvas-editor__portal-menu"
label="视频参数选项"
placement="top-start"
style={buildPortalMenuStyle(
paramsButtonRef.current,
'above',
)}
onPointerDown={(event) => event.stopPropagation()}
>
<div className="image-canvas-editor__option-popover-sections">
<section className="image-canvas-editor__option-popover-section">
<span className="image-canvas-editor__option-popover-title">
比例
</span>
<div className="image-canvas-editor__option-popover-items image-canvas-editor__option-popover-items--card">
{EDITOR_VIDEO_ASPECT_RATIO_OPTIONS.map((option) => (
<VideoOptionChoice
key={option}
selected={aspectRatio === option}
className="image-canvas-editor__option-popover-choice--ratio"
disabled={isGenerating}
ariaLabel={`比例 ${option}`}
onClick={() =>
updateVideoDialog({
videoAspectRatio: option,
})
}
>
<span
className="image-canvas-editor__ratio-wireframe"
data-ratio={option}
aria-hidden="true"
/>
<span>{option}</span>
</VideoOptionChoice>
))}
</div>
</section>
<section className="image-canvas-editor__option-popover-section">
<span className="image-canvas-editor__option-popover-title">
时长
</span>
<div className="image-canvas-editor__video-duration-slider">
<input
type="range"
min={4}
max={15}
step={1}
value={durationSeconds}
disabled={isGenerating}
aria-label="视频时长"
onChange={(event) =>
updateVideoDialog({
videoDurationSeconds: normalizeVideoDuration(
Number(event.target.value),
),
})
}
/>
<span>{durationSeconds}</span>
</div>
</section>
<section className="image-canvas-editor__option-popover-section">
<span className="image-canvas-editor__option-popover-title">
清晰度
</span>
<div className="image-canvas-editor__option-popover-items">
{EDITOR_VIDEO_RESOLUTION_OPTIONS.map((option) => {
const available =
currentModel.value !==
VIDEO_MODEL_SEEDANCE_2_FAST ||
option !== '1080p';
return (
<VideoOptionChoice
key={option}
selected={resolution === option}
disabled={isGenerating || !available}
ariaLabel={`清晰度 ${option}`}
onClick={() =>
available
? updateVideoDialog({
videoResolution: option,
})
: undefined
}
>
{option}
</VideoOptionChoice>
);
})}
</div>
</section>
<section className="image-canvas-editor__option-popover-section">
<span className="image-canvas-editor__option-popover-title">
声音
</span>
<div className="image-canvas-editor__video-toggle-row">
<button
type="button"
className="image-canvas-editor__video-toggle"
aria-pressed={soundMode === 'off'}
disabled={isGenerating}
onClick={() =>
updateVideoDialog({
videoSound: soundMode === 'off' ? 'on' : 'off',
})
}
>
<span aria-hidden="true" />
<strong>静音</strong>
</button>
</div>
</section>
</div>
</PlatformFloatingMenu>,
)
: null}
</div>
<div className="image-canvas-editor__option-popover-anchor image-canvas-editor__option-popover-anchor--model">
<PlatformInlineOptionButton
ref={modelButtonRef}
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--model"
aria-label={`模型 ${currentModel.label}`}
aria-expanded={openPanel === 'model'}
disabled={isGenerating}
trailingIcon={<ChevronDown className="h-3 w-3" />}
onClick={() =>
setOpenPanel((currentPanel) =>
currentPanel === 'model' ? null : 'model',
)
}
>
<span className="image-canvas-editor__model-trigger-label">
<span
className="image-canvas-editor__model-icon"
aria-hidden="true"
>
<Cpu />
</span>
<span>{currentModel.label}</span>
</span>
</PlatformInlineOptionButton>
{openPanel === 'model'
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__option-popover image-canvas-editor__option-popover--model image-canvas-editor__portal-menu"
label="视频模型选项"
placement="top-start"
style={buildPortalMenuStyle(
modelButtonRef.current,
'above',
)}
onPointerDown={(event) => event.stopPropagation()}
>
<div className="image-canvas-editor__option-popover-items image-canvas-editor__option-popover-items--model">
{EDITOR_VIDEO_MODEL_OPTIONS.map((option) => {
const checked = currentModel.value === option.value;
return (
<VideoOptionChoice
key={option.value}
selected={checked}
disabled={isGenerating}
className="image-canvas-editor__option-popover-choice--model"
ariaLabel={option.label}
onClick={() => updateVideoModel(option.value)}
>
<span
className="image-canvas-editor__model-icon"
aria-hidden="true"
>
<Cpu />
</span>
<span>{option.label}</span>
<Check
className="image-canvas-editor__option-selected-check"
data-visible={checked}
aria-hidden="true"
/>
</VideoOptionChoice>
);
})}
</div>
</PlatformFloatingMenu>,
)
: null}
</div>
<PlatformActionButton
type="button"
tone="secondary"
size="xs"
shape="pill"
className="image-canvas-editor__generation-submit"
disabled={isGenerating}
aria-label="生成视频"
onClick={() => {
if (!isGenerating) {
onSubmit(dialog);
}
}}
>
{isGenerating ? (
'生成中'
) : (
<>
<span>生成</span>
<span className="image-canvas-editor__mud-point-inline">
{price}泥点
</span>
</>
)}
</PlatformActionButton>
</div>
</div>
{isGenerationReferenceMenuOpen && supportsReferences
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__spec-menu image-canvas-editor__portal-menu"
label="参考素材来源"
placement="top-start"
style={buildPortalMenuStyle(
generationReferenceButtonRef.current,
'above',
)}
>
<PlatformFloatingMenuItem
onClick={() => {
setIsGenerationReferenceMenuOpen(false);
setIsPickingGenerationReferenceFromCanvas(true);
}}
>
从画布中选择
</PlatformFloatingMenuItem>
<PlatformFloatingMenuItem
onClick={() => {
setIsGenerationReferenceMenuOpen(false);
setIsPickingGenerationReferenceFromCanvas(false);
onRequestUpload('video-reference-image');
}}
>
上传图片
</PlatformFloatingMenuItem>
<PlatformFloatingMenuItem
onClick={() => {
setIsGenerationReferenceMenuOpen(false);
setIsPickingGenerationReferenceFromCanvas(false);
onRequestUpload('video-reference-video');
}}
>
上传视频
</PlatformFloatingMenuItem>
<PlatformFloatingMenuItem
onClick={() => {
setIsGenerationReferenceMenuOpen(false);
setIsPickingGenerationReferenceFromCanvas(false);
onRequestUpload('video-reference-audio');
}}
>
上传音频
</PlatformFloatingMenuItem>
</PlatformFloatingMenu>,
)
: null}
</>
);
}
function normalizeSoundDuration(value: number | null | undefined) {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return DEFAULT_SOUND_EFFECT_DURATION_SECONDS;
}
return Math.min(
SOUND_EFFECT_DURATION_MAX_SECONDS,
Math.max(SOUND_EFFECT_DURATION_MIN_SECONDS, value),
);
}
const BACKGROUND_MUSIC_COMPOSER_LABEL = '生成游戏背景音乐';
const BACKGROUND_MUSIC_MODEL_LABEL = 'Suno';
/**
* 音效与背景音乐共用同一个 composer 边界;视图内按 mode 分流,业务规则、
* Prompt 写回和锁定语义仍分别保持。
*/
function ImageCanvasAudioGenerationComposerView({
dialog,
style,
renderEditorPortal,
buildPortalMenuStyle,
promptAssist,
soundEffectPromptAssist,
updateCanvasGenerationDialogById,
onSubmit,
}: {
dialog: GenerateDialogState;
style: CSSProperties;
renderEditorPortal: (node: ReactNode) => ReactNode;
buildPortalMenuStyle: (
anchor: HTMLElement | null,
placement: 'above' | 'below',
) => CSSProperties;
promptAssist?: BackgroundMusicPromptAssistComposerController;
soundEffectPromptAssist?: SoundEffectPromptAssistComposerController;
updateCanvasGenerationDialogById: (
dialogId: string,
updater: (
dialog: CanvasGenerationDialogState,
) => CanvasGenerationDialogState | null,
) => void;
onSubmit: (dialog: GenerateDialogState) => void;
}) {
const [isSoundOptionsOpen, setIsSoundOptionsOpen] = useState(false);
const soundOptionsButtonRef = useRef<HTMLButtonElement | null>(null);
const soundAutoDurationRef = useRef<HTMLInputElement | null>(null);
const soundModelButtonRef = useRef<HTMLButtonElement | null>(null);
const promptCounterId = useId();
const isSoundEffect = dialog.mode === 'audio-sound-effect';
const isGenerating = dialog.status === 'generating';
useImageCanvasFloatingOptionDismiss({
isOpen: isSoundEffect && isSoundOptionsOpen,
boundaryRefs: [soundOptionsButtonRef],
onDismiss: () => setIsSoundOptionsOpen(false),
restoreFocusRef: soundOptionsButtonRef,
});
useEffect(() => {
if (!isSoundEffect || !isSoundOptionsOpen) {
return;
}
// 浮层 portal 到 body,Tab 顺序离触发按钮很远;不主动移焦,复选框和滑块键盘不可达。
soundAutoDurationRef.current?.focus();
}, [isSoundEffect, isSoundOptionsOpen]);
if (!isSoundEffect) {
const backgroundMusicDialog = toBackgroundMusicGenerationDialog(dialog);
const backgroundMusicPromptAssist = promptAssist;
if (!backgroundMusicDialog || !backgroundMusicPromptAssist) {
return null;
}
const assistState = backgroundMusicPromptAssist.getDialogState(
backgroundMusicDialog.id,
);
const isAiProcessing =
assistState.status === 'completing' ||
assistState.status === 'simplifying';
const isSubmitting = assistState.status === 'submitting';
// AI 处理、提交锁和现有生成占位都只锁当前 BGM 面板。
const isLocked = isAiProcessing || isSubmitting || isGenerating;
// 计数与动作状态读取 canonical preview,不在输入事件里改写输入框。
const promptCodePointCount = countPromptCodePoints(
backgroundMusicDialog.prompt,
);
const isPromptOverLimit =
promptCodePointCount > BACKGROUND_MUSIC_PROMPT_MAX_CODE_POINTS;
const canComplete =
!isLocked &&
canCompleteBackgroundMusicPrompt(backgroundMusicDialog.prompt);
const canSimplify =
!isLocked &&
canSimplifyBackgroundMusicPrompt(backgroundMusicDialog.prompt);
const canGenerate =
!isLocked &&
canGenerateBackgroundMusicFromPrompt(backgroundMusicDialog.prompt);
// AI 处理中临时快照仍需让撤销按钮留在可访问树中,但必须禁用。
const isUndoVisible =
assistState.undoPromptSnapshot !== null ||
assistState.temporaryPromptSnapshot !== null;
const canUndo = isUndoVisible && !isLocked;
const cost = calculateEditorBackgroundMusicPrice(
DEFAULT_BACKGROUND_MUSIC_MODEL,
);
const updateBackgroundMusicPrompt = (prompt: string) => {
updateCanvasGenerationDialogById(
backgroundMusicDialog.id,
(currentDialog) => ({
...currentDialog,
prompt,
status:
currentDialog.status === 'failed' ? 'idle' : currentDialog.status,
errorMessage:
currentDialog.status === 'failed'
? undefined
: currentDialog.errorMessage,
}),
);
};
const handlePresetClick = (preset: BackgroundMusicPromptPreset) => {
const canonicalPrompt = backgroundMusicPromptAssist.preparePreset(
backgroundMusicDialog.id,
);
if (canonicalPrompt === null) {
return;
}
updateBackgroundMusicPrompt(
appendBackgroundMusicPromptPreset(canonicalPrompt, preset),
);
};
return (
<div
className="image-canvas-editor__generation-composer image-canvas-editor__generation-composer--image image-canvas-editor__generation-composer--audio image-canvas-editor__generation-composer--background-music"
style={style}
role="dialog"
aria-label={BACKGROUND_MUSIC_COMPOSER_LABEL}
aria-busy={isLocked}
onPointerDown={(event) => event.stopPropagation()}
>
{/* 与生图 / 生视频 / 角色 / 图标同源:自动增高到 max-h-32 后内部滚动。 */}
<AutoGrowTextArea
aria-label="gpt_description_prompt"
aria-describedby={promptCounterId}
aria-invalid={isPromptOverLimit || undefined}
value={backgroundMusicDialog.prompt}
readOnly={isLocked}
placeholder="你希望生成什么音乐?"
className="image-canvas-editor__generation-prompt"
onValueChange={updateBackgroundMusicPrompt}
/>
<ImageCanvasBackgroundMusicPresetMarquee
presets={BACKGROUND_MUSIC_PROMPT_PRESETS}
isLocked={isLocked}
onSelectPreset={handlePresetClick}
headerTrailing={
<span
id={promptCounterId}
className={
isPromptOverLimit
? 'image-canvas-editor__background-music-prompt-count image-canvas-editor__background-music-prompt-count--over-limit'
: 'image-canvas-editor__background-music-prompt-count'
}
>
{`${promptCodePointCount} / ${BACKGROUND_MUSIC_PROMPT_MAX_CODE_POINTS}`}
</span>
}
/>
{assistState.errorMessage ? (
<PlatformStatusMessage
tone="error"
surface="platform"
size="xs"
className="image-canvas-editor__generate-status"
role="alert"
>
{assistState.errorMessage}
</PlatformStatusMessage>
) : null}
{backgroundMusicDialog.status === 'failed' ? (
<PlatformStatusMessage
tone="error"
surface="platform"
size="xs"
className="image-canvas-editor__generate-status"
role="alert"
>
{backgroundMusicDialog.errorMessage}
</PlatformStatusMessage>
) : null}
<div className="image-canvas-editor__generation-composer-footer">
<div className="image-canvas-editor__background-music-prompt-actions">
<PlatformActionButton
type="button"
tone="secondary"
size="xs"
shape="pill"
className="image-canvas-editor__background-music-prompt-action image-canvas-editor__background-music-prompt-action--complete"
aria-label="AI 补全"
disabled={!canComplete}
onClick={() => {
void backgroundMusicPromptAssist.completePrompt(
backgroundMusicDialog.id,
);
}}
>
<span
className="image-canvas-editor__background-music-prompt-action-icon"
aria-hidden="true"
>
<Sparkles />
</span>
<span>
{assistState.status === 'completing' ? '补全中' : 'AI 补全'}
</span>
</PlatformActionButton>
<PlatformActionButton
type="button"
tone="secondary"
size="xs"
shape="pill"
className="image-canvas-editor__background-music-prompt-action image-canvas-editor__background-music-prompt-action--simplify"
aria-label="一键简化"
disabled={!canSimplify}
onClick={() => {
void backgroundMusicPromptAssist.simplifyPrompt(
backgroundMusicDialog.id,
);
}}
>
<span
className="image-canvas-editor__background-music-prompt-action-icon"
aria-hidden="true"
>
<WandSparkles />
</span>
<span>
{assistState.status === 'simplifying' ? '简化中' : '一键简化'}
</span>
</PlatformActionButton>
{isUndoVisible ? (
<PlatformActionButton
type="button"
tone="ghost"
size="xs"
shape="pill"
className="image-canvas-editor__background-music-prompt-action image-canvas-editor__background-music-prompt-action--undo"
disabled={!canUndo}
onClick={() => {
backgroundMusicPromptAssist.undoPrompt(
backgroundMusicDialog.id,
);
}}
>
<span
className="image-canvas-editor__background-music-prompt-action-icon"
aria-hidden="true"
>
<Undo2 />
</span>
<span>撤销</span>
</PlatformActionButton>
) : null}
</div>
<div className="image-canvas-editor__option-popover-anchor image-canvas-editor__option-popover-anchor--model image-canvas-editor__readonly-generation-option">
<PlatformInlineOptionButton
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--model"
aria-label={`背景音乐模型 ${BACKGROUND_MUSIC_MODEL_LABEL}`}
disabled
>
<span className="image-canvas-editor__model-trigger-label">
<span
className="image-canvas-editor__model-icon"
aria-hidden="true"
>
<Music />
</span>
<span>{BACKGROUND_MUSIC_MODEL_LABEL}</span>
</span>
</PlatformInlineOptionButton>
</div>
<PlatformActionButton
type="button"
tone="secondary"
size="xs"
shape="pill"
className="image-canvas-editor__generation-submit"
disabled={!canGenerate}
aria-label={BACKGROUND_MUSIC_COMPOSER_LABEL}
onClick={() => {
if (canGenerate) {
onSubmit(backgroundMusicDialog);
}
}}
>
{isGenerating ? (
'生成中'
) : (
<>
<span>生成</span>
<span className="image-canvas-editor__mud-point-inline">
{cost}泥点
</span>
</>
)}
</PlatformActionButton>
</div>
</div>
);
}
const soundEffectDialog = toSoundEffectGenerationDialog(dialog);
const controller = soundEffectPromptAssist;
if (!soundEffectDialog || !controller) {
return null;
}
const assistState = controller.getDialogState(soundEffectDialog.id);
const isAiProcessing = assistState.status === 'optimizing';
const isSubmitting = assistState.status === 'submitting';
const isLocked = isAiProcessing || isSubmitting || isGenerating;
const promptCodePointCount = countSoundEffectPromptCodePoints(
soundEffectDialog.prompt,
);
const isPromptOverLimit =
promptCodePointCount > SOUND_EFFECT_PROMPT_MAX_CODE_POINTS;
const canOptimize =
!isLocked && canOptimizeSoundEffectPrompt(soundEffectDialog.prompt);
const canGenerate =
!isLocked && canGenerateSoundEffectFromPrompt(soundEffectDialog.prompt);
const isUndoVisible =
assistState.undoPromptSnapshot !== null ||
assistState.temporaryPromptSnapshot !== null;
const canUndo = isUndoVisible && !isLocked;
const soundModel = DEFAULT_SOUND_EFFECT_MODEL;
const currentSoundModel =
EDITOR_SOUND_EFFECT_MODEL_OPTIONS.find(
(option) => option.value === soundModel,
) ?? EDITOR_SOUND_EFFECT_MODEL_OPTIONS[0];
const soundDuration = normalizeSoundDuration(
soundEffectDialog.soundDurationSeconds,
);
const durationMode =
soundEffectDialog.soundDurationMode === 'auto' ? 'auto' : 'manual';
const isAutomaticDuration = durationMode === 'auto';
const soundLoop = soundEffectDialog.soundLoop === true;
const soundOptionLabel = isAutomaticDuration
? '自动时长'
: `${soundDuration}秒`;
const dialogLabel = '生成游戏音效';
const fixedModelLabel = currentSoundModel.label;
const fixedModelAriaLabel = `音效模型 ${fixedModelLabel}`;
const cost = calculateEditorSoundEffectPrice(soundModel);
const updateAudioDialog = (
patch: Partial<
Pick<
GenerateDialogState,
'prompt' | 'soundDurationMode' | 'soundDurationSeconds' | 'soundLoop'
>
>,
) => {
updateCanvasGenerationDialogById(soundEffectDialog.id, (currentDialog) =>
currentDialog.mode === 'audio-sound-effect'
? {
...currentDialog,
status:
currentDialog.status === 'failed' ? 'idle' : currentDialog.status,
errorMessage:
currentDialog.status === 'failed'
? undefined
: currentDialog.errorMessage,
...patch,
}
: currentDialog,
);
};
const updateSoundDuration = (durationSeconds: number) => {
updateAudioDialog({
soundDurationSeconds: normalizeSoundDuration(durationSeconds),
});
};
return (
<div
className="image-canvas-editor__generation-composer image-canvas-editor__generation-composer--image image-canvas-editor__generation-composer--audio image-canvas-editor__generation-composer--sound-effect"
style={style}
role="dialog"
aria-label={dialogLabel}
aria-busy={isLocked}
onPointerDown={(event) => event.stopPropagation()}
>
<AutoGrowTextArea
aria-label="prompt"
aria-describedby={promptCounterId}
aria-invalid={isPromptOverLimit || undefined}
value={soundEffectDialog.prompt}
readOnly={isLocked}
placeholder="你希望生成什么音效?"
className="image-canvas-editor__generation-prompt"
onValueChange={(value) => updateAudioDialog({ prompt: value })}
/>
<ImageCanvasSoundEffectPresetMarquee
presets={SOUND_EFFECT_PROMPT_PRESETS}
isLocked={isLocked}
onSelectPreset={(preset: SoundEffectPromptPreset) => {
const canonicalPrompt = controller.preparePreset(
soundEffectDialog.id,
);
if (canonicalPrompt === null) {
return;
}
updateAudioDialog({
prompt: appendSoundEffectPromptPreset(canonicalPrompt, preset),
});
}}
headerTrailing={
<span
id={promptCounterId}
className={
isPromptOverLimit
? 'image-canvas-editor__background-music-prompt-count image-canvas-editor__background-music-prompt-count--over-limit'
: 'image-canvas-editor__background-music-prompt-count'
}
>
{`${promptCodePointCount} / ${SOUND_EFFECT_PROMPT_MAX_CODE_POINTS}`}
</span>
}
/>
{assistState.errorMessage ? (
<PlatformStatusMessage
tone="error"
surface="platform"
size="xs"
className="image-canvas-editor__generate-status"
role="alert"
>
{assistState.errorMessage}
</PlatformStatusMessage>
) : null}
{soundEffectDialog.status === 'failed' ? (
<PlatformStatusMessage
tone="error"
surface="platform"
size="xs"
className="image-canvas-editor__generate-status"
role="alert"
>
{soundEffectDialog.errorMessage}
</PlatformStatusMessage>
) : null}
<div className="image-canvas-editor__generation-composer-footer">
<div className="image-canvas-editor__background-music-prompt-actions image-canvas-editor__sound-effect-prompt-actions">
<PlatformActionButton
type="button"
tone="secondary"
size="xs"
shape="pill"
className="image-canvas-editor__background-music-prompt-action image-canvas-editor__sound-effect-prompt-action--optimize"
aria-label="一键优化"
disabled={!canOptimize}
onClick={() => {
void controller.optimizePrompt(soundEffectDialog.id);
}}
>
<span
className="image-canvas-editor__background-music-prompt-action-icon"
aria-hidden="true"
>
<WandSparkles />
</span>
<span>{isAiProcessing ? '优化中' : '一键优化'}</span>
</PlatformActionButton>
{isUndoVisible ? (
<PlatformActionButton
type="button"
tone="ghost"
size="xs"
shape="pill"
className="image-canvas-editor__background-music-prompt-action image-canvas-editor__background-music-prompt-action--undo"
disabled={!canUndo}
onClick={() => controller.undoPrompt(soundEffectDialog.id)}
>
<span
className="image-canvas-editor__background-music-prompt-action-icon"
aria-hidden="true"
>
<Undo2 />
</span>
<span>撤销</span>
</PlatformActionButton>
) : null}
</div>
<div className="image-canvas-editor__option-popover-anchor image-canvas-editor__option-popover-anchor--dimensions">
<PlatformInlineOptionButton
ref={soundOptionsButtonRef}
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--dimensions"
aria-label={`音效时长 ${soundOptionLabel}`}
aria-expanded={isSoundOptionsOpen}
disabled={isLocked}
trailingIcon={<ChevronDown className="h-3 w-3" />}
onClick={() => setIsSoundOptionsOpen((isOpen) => !isOpen)}
>
<span
className="image-canvas-editor__sound-effect-option-icon"
aria-hidden="true"
>
<Clock />
</span>
<span>{soundOptionLabel}</span>
</PlatformInlineOptionButton>
{isSoundOptionsOpen
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__option-popover image-canvas-editor__option-popover--audio-options image-canvas-editor__portal-menu"
label="音效时长选项"
variant="options"
placement="top-start"
style={buildPortalMenuStyle(
soundOptionsButtonRef.current,
'above',
)}
>
<div className="image-canvas-editor__option-popover-sections">
<section className="image-canvas-editor__option-popover-section">
<span className="image-canvas-editor__option-popover-title">
时长
</span>
<label className="image-canvas-editor__sound-effect-auto-duration">
<input
ref={soundAutoDurationRef}
type="checkbox"
aria-label="自动时长"
checked={isAutomaticDuration}
disabled={isLocked}
onChange={(event) =>
updateAudioDialog({
soundDurationMode: event.target.checked
? 'auto'
: 'manual',
})
}
/>
<span>自动时长</span>
</label>
<div className="image-canvas-editor__video-duration-slider">
<input
type="range"
min={SOUND_EFFECT_DURATION_MIN_SECONDS}
max={SOUND_EFFECT_DURATION_MAX_SECONDS}
step={0.1}
value={soundDuration}
disabled={isLocked || isAutomaticDuration}
aria-label="手动音效时长"
onChange={(event) =>
updateSoundDuration(Number(event.target.value))
}
/>
<span>{soundDuration}</span>
</div>
</section>
</div>
</PlatformFloatingMenu>,
)
: null}
</div>
<PlatformInlineOptionButton
className="image-canvas-editor__option-cluster image-canvas-editor__sound-effect-loop"
aria-label={`循环 ${soundLoop ? '开' : '关'}`}
aria-pressed={soundLoop}
disabled={isLocked}
onClick={() => updateAudioDialog({ soundLoop: !soundLoop })}
>
<span
className="image-canvas-editor__sound-effect-option-icon"
aria-hidden="true"
>
<Repeat />
</span>
<span>循环</span>
<span className="image-canvas-editor__sound-effect-loop-state">
{soundLoop ? '开' : '关'}
</span>
</PlatformInlineOptionButton>
<div className="image-canvas-editor__option-popover-anchor image-canvas-editor__option-popover-anchor--model image-canvas-editor__readonly-generation-option">
<PlatformInlineOptionButton
ref={soundModelButtonRef}
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--model"
aria-label={fixedModelAriaLabel}
disabled
>
<span className="image-canvas-editor__model-trigger-label">
<span
className="image-canvas-editor__model-icon"
aria-hidden="true"
>
<Music />
</span>
<span>{fixedModelLabel}</span>
</span>
</PlatformInlineOptionButton>
</div>
<PlatformActionButton
type="button"
tone="secondary"
size="xs"
shape="pill"
className="image-canvas-editor__generation-submit"
disabled={!canGenerate}
aria-label={dialogLabel}
onClick={() => {
// 与 disabled 用同一个 canGenerate 做冗余守卫:原生 disabled 已经挡住点击,
// 但若将来去掉 disabled 或换成非原生按钮,空提示词、超限和优化中仍不能把
// 付费请求发出去。soundEffectDialog 与 dialog 值相同,这里取前者只为与
// 本分支其余 SFX 代码保持一致。
if (canGenerate) {
onSubmit(soundEffectDialog);
}
}}
>
{isGenerating ? (
'生成中'
) : (
<>
<span>生成</span>
<span className="image-canvas-editor__mud-point-inline">
{cost}泥点
</span>
</>
)}
</PlatformActionButton>
</div>
</div>
);
}
export function ImageCanvasGenerationComposerView({
specToolWrapRef,
publicationReferenceButtonRef,
characterSpecButtonRef,
characterReferenceButtonRef,
generationReferenceButtonRef,
iconSpecButtonRef,
isSpecMenuOpen,
isGenerationReferenceMenuOpen,
isPublicationReferenceMenuOpen,
isCharacterSpecMenuOpen,
isCharacterReferenceMenuOpen,
isIconSpecMenuOpen,
isUiDesignSpecMenuOpen,
isPickingGenerationReferenceFromCanvas,
isPickingQuickEditReferenceFromCanvas,
isPickingPublicationReferenceFromCanvas,
isPickingCharacterSpecFromCanvas,
isPickingCharacterReferenceFromCanvas,
isPickingIconSpecFromCanvas,
isPickingUiDesignSpecFromCanvas,
generateDialog,
backgroundMusicPromptAssist,
soundEffectPromptAssist,
updateCanvasGenerationDialogById,
generationComposerStyle,
iconComposerStyle,
quickEditPanel,
quickEditSourceLayer,
quickEditPanelStyle,
cropExpandPanel,
cropExpandSourceLayer,
cropExpandPanelStyle,
characterAnimationPanel,
characterAnimationSourceLayer,
characterAnimationPanelStyle,
characterAnimationPrice,
setGenerateDialog,
setQuickEditPanel,
setCropExpandPanel,
setCharacterAnimationPanel,
setIsGenerationReferenceMenuOpen,
setIsPublicationReferenceMenuOpen,
setIsCharacterSpecMenuOpen,
setIsCharacterReferenceMenuOpen,
setIsIconSpecMenuOpen,
setIsUiDesignSpecMenuOpen,
setIsPickingGenerationReferenceFromCanvas,
setIsPickingQuickEditReferenceFromCanvas,
setIsPickingPublicationReferenceFromCanvas,
setIsPickingCharacterSpecFromCanvas,
setIsPickingCharacterReferenceFromCanvas,
setIsPickingIconSpecFromCanvas,
setIsPickingUiDesignSpecFromCanvas,
hasPendingImageReferenceUploads = false,
onOpenSpecDialog,
onRequestUpload,
onRequestProjectAssetPicker,
onSubmitImageGeneration,
onSubmitIconSpritesheetGeneration,
onSubmitQuickEdit,
onSubmitCropExpand,
onSubmitCharacterAnimation,
onRetryPerfectPixelOperation,
onUpdateSpecFormValue,
onUpdateIconDescriptionText,
onUpdateCharacterAnimationDuration,
onRememberImageModel,
onSpecMenuPointerEnter,
onSpecMenuPointerLeave,
}: ImageCanvasGenerationComposerViewProps) {
const isPerfectPixelDialog = Boolean(
generateDialog?.perfectPixelOperation ||
generateDialog?.perfectPixelOperationInvalid,
);
return (
<>
{isSpecMenuOpen
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__spec-menu image-canvas-editor__portal-menu"
label="生成规范类型"
placement="top-start"
style={buildPortalMenuStyle(specToolWrapRef.current, 'above')}
onPointerEnter={onSpecMenuPointerEnter}
onPointerLeave={onSpecMenuPointerLeave}
onFocus={onSpecMenuPointerEnter}
onBlur={onSpecMenuPointerLeave}
>
{(['character', 'icon', 'custom'] as const).map((specType) => (
<PlatformFloatingMenuItem
key={specType}
className="image-canvas-editor__spec-menu-item"
onClick={() => onOpenSpecDialog(specType)}
>
{SPEC_TYPE_LABEL[specType]}
</PlatformFloatingMenuItem>
))}
</PlatformFloatingMenu>,
)
: null}
{isPerfectPixelDialog &&
generateDialog &&
generateDialog.composerOpen !== false &&
generationComposerStyle ? (
<PerfectPixelOperationPanel
dialog={generateDialog}
style={generationComposerStyle}
onRetry={onRetryPerfectPixelOperation}
/>
) : null}
{!isPerfectPixelDialog &&
(generateDialog?.mode === 'generate' ||
generateDialog?.mode === 'quick-edit' ||
generateDialog?.mode === 'scene') &&
generateDialog.composerOpen !== false &&
generationComposerStyle ? (
<ImageCanvasBasicGenerationComposerView
dialog={generateDialog}
style={generationComposerStyle}
setGenerateDialog={setGenerateDialog}
generationReferenceButtonRef={generationReferenceButtonRef}
isGenerationReferenceMenuOpen={isGenerationReferenceMenuOpen}
setIsGenerationReferenceMenuOpen={setIsGenerationReferenceMenuOpen}
setIsPickingGenerationReferenceFromCanvas={
setIsPickingGenerationReferenceFromCanvas
}
renderEditorPortal={renderEditorPortal}
buildPortalMenuStyle={buildPortalMenuStyle}
onRequestUpload={onRequestUpload}
onRequestProjectAssetPicker={onRequestProjectAssetPicker}
onToggleReferenceMenu={() =>
setIsGenerationReferenceMenuOpen((open) => !open)
}
onRememberImageModel={onRememberImageModel}
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
onSubmit={onSubmitImageGeneration}
dialogLabel={
generateDialog.mode === 'scene' ? '游戏场景生成器' : undefined
}
promptLabel={generateDialog.mode === 'scene' ? '画面内容' : undefined}
promptPlaceholder={
generateDialog.mode === 'scene'
? '描述你想生成的游戏场景,例如:雨夜中的欧洲小镇街道……'
: undefined
}
optionLabelPrefix={
generateDialog.mode === 'scene' ? '游戏场景' : undefined
}
referenceButtonLabel={
generateDialog.mode === 'scene' ? '参考图' : undefined
}
styleControl={
generateDialog.mode === 'scene' ? (
<ImageCanvasSceneStyleControl
dialog={generateDialog}
setGenerateDialog={setGenerateDialog}
renderEditorPortal={renderEditorPortal}
buildPortalMenuStyle={buildPortalMenuStyle}
/>
) : undefined
}
/>
) : null}
{generateDialog?.mode === 'spec' &&
generateDialog.composerOpen !== false &&
generationComposerStyle ? (
<ImageCanvasSpecGenerationPanelView
dialog={generateDialog}
style={generationComposerStyle}
isGenerationReferenceMenuOpen={isGenerationReferenceMenuOpen}
generationReferenceButtonRef={generationReferenceButtonRef}
setIsGenerationReferenceMenuOpen={setIsGenerationReferenceMenuOpen}
setIsPickingGenerationReferenceFromCanvas={
setIsPickingGenerationReferenceFromCanvas
}
renderEditorPortal={renderEditorPortal}
buildPortalMenuStyle={buildPortalMenuStyle}
setGenerateDialog={setGenerateDialog}
onOpenSpecDialog={onOpenSpecDialog}
onUpdateSpecFormValue={onUpdateSpecFormValue}
onRequestUpload={onRequestUpload}
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
onSubmit={onSubmitImageGeneration}
/>
) : null}
{generateDialog?.mode === 'publication' &&
generateDialog.composerOpen !== false &&
generationComposerStyle ? (
<ImageCanvasPublicationMaterialsDemoPanelView
dialog={generateDialog}
workflow={getPublicationMaterialsWorkflow(
generateDialog.publicationWorkflowId ?? 'publication-cover-image',
)}
style={generationComposerStyle}
publicationReferenceButtonRef={publicationReferenceButtonRef}
isPublicationReferenceMenuOpen={isPublicationReferenceMenuOpen}
setGenerateDialog={setGenerateDialog}
setIsPublicationReferenceMenuOpen={setIsPublicationReferenceMenuOpen}
setIsPickingPublicationReferenceFromCanvas={
setIsPickingPublicationReferenceFromCanvas
}
renderEditorPortal={renderEditorPortal}
buildPortalMenuStyle={buildPortalMenuStyle}
onRequestUpload={onRequestUpload}
onRememberImageModel={onRememberImageModel}
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
onSubmit={onSubmitImageGeneration}
/>
) : null}
{generateDialog?.mode === 'ui-design' &&
generateDialog.composerOpen !== false &&
generationComposerStyle ? (
<ImageCanvasSpecGenerationPanelView
dialog={generateDialog}
style={generationComposerStyle}
isGenerationReferenceMenuOpen={isUiDesignSpecMenuOpen}
generationReferenceButtonRef={generationReferenceButtonRef}
setIsGenerationReferenceMenuOpen={setIsUiDesignSpecMenuOpen}
setIsPickingGenerationReferenceFromCanvas={
setIsPickingUiDesignSpecFromCanvas
}
renderEditorPortal={renderEditorPortal}
buildPortalMenuStyle={buildPortalMenuStyle}
setGenerateDialog={setGenerateDialog}
onOpenSpecDialog={onOpenSpecDialog}
onUpdateSpecFormValue={onUpdateSpecFormValue}
onRequestUpload={onRequestUpload}
onRememberImageModel={onRememberImageModel}
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
onSubmit={onSubmitImageGeneration}
/>
) : null}
{generateDialog?.mode === 'video' &&
generateDialog.composerOpen !== false &&
generationComposerStyle ? (
<ImageCanvasVideoGenerationComposerView
dialog={generateDialog}
style={generationComposerStyle}
generationReferenceButtonRef={generationReferenceButtonRef}
isGenerationReferenceMenuOpen={isGenerationReferenceMenuOpen}
setGenerateDialog={setGenerateDialog}
setIsGenerationReferenceMenuOpen={setIsGenerationReferenceMenuOpen}
setIsPickingGenerationReferenceFromCanvas={
setIsPickingGenerationReferenceFromCanvas
}
renderEditorPortal={renderEditorPortal}
buildPortalMenuStyle={buildPortalMenuStyle}
onRequestUpload={onRequestUpload}
onSubmit={onSubmitImageGeneration}
/>
) : null}
{(generateDialog?.mode === 'audio-sound-effect' ||
generateDialog?.mode === 'audio-background-music') &&
generateDialog.composerOpen !== false &&
generationComposerStyle ? (
<ImageCanvasAudioGenerationComposerView
// dialog ID 与 mode 共同隔离 BGM 预设轨道和 SFX 时长菜单的局部状态。
key={`${generateDialog.mode}:${generateDialog.id ?? 'without-id'}`}
dialog={generateDialog}
style={generationComposerStyle}
promptAssist={backgroundMusicPromptAssist}
soundEffectPromptAssist={soundEffectPromptAssist}
renderEditorPortal={renderEditorPortal}
buildPortalMenuStyle={buildPortalMenuStyle}
updateCanvasGenerationDialogById={updateCanvasGenerationDialogById}
onSubmit={onSubmitImageGeneration}
/>
) : null}
{generateDialog?.mode === 'character' && generationComposerStyle ? (
<ImageCanvasCharacterGenerationComposerView
dialog={generateDialog}
style={generationComposerStyle}
characterSpecButtonRef={characterSpecButtonRef}
characterReferenceButtonRef={characterReferenceButtonRef}
isCharacterSpecMenuOpen={isCharacterSpecMenuOpen}
isCharacterReferenceMenuOpen={isCharacterReferenceMenuOpen}
setGenerateDialog={setGenerateDialog}
setIsCharacterSpecMenuOpen={setIsCharacterSpecMenuOpen}
setIsCharacterReferenceMenuOpen={setIsCharacterReferenceMenuOpen}
setIsPickingCharacterSpecFromCanvas={
setIsPickingCharacterSpecFromCanvas
}
setIsPickingCharacterReferenceFromCanvas={
setIsPickingCharacterReferenceFromCanvas
}
renderEditorPortal={renderEditorPortal}
buildPortalMenuStyle={buildPortalMenuStyle}
onOpenSpecDialog={onOpenSpecDialog}
onRequestUpload={onRequestUpload}
onRememberImageModel={onRememberImageModel}
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
onSubmit={onSubmitImageGeneration}
/>
) : null}
{generateDialog?.mode === 'icon' &&
generateDialog.composerOpen !== false &&
iconComposerStyle ? (
<ImageCanvasIconSpritesheetComposerView
dialog={generateDialog}
style={iconComposerStyle}
iconSpecButtonRef={iconSpecButtonRef}
isIconSpecMenuOpen={isIconSpecMenuOpen}
setGenerateDialog={setGenerateDialog}
setIsIconSpecMenuOpen={setIsIconSpecMenuOpen}
setIsPickingIconSpecFromCanvas={setIsPickingIconSpecFromCanvas}
renderEditorPortal={renderEditorPortal}
buildPortalMenuStyle={buildPortalMenuStyle}
onOpenSpecDialog={onOpenSpecDialog}
onRequestUpload={onRequestUpload}
onUpdateIconDescriptionText={onUpdateIconDescriptionText}
onRememberImageModel={onRememberImageModel}
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
onSubmit={onSubmitIconSpritesheetGeneration}
/>
) : null}
{isPickingCharacterSpecFromCanvas ? (
<div className="image-canvas-editor__canvas-pick-hint">
请选择画布中的图片作为角色规范,按 Esc 退出
</div>
) : null}
{isPickingCharacterReferenceFromCanvas ? (
<div className="image-canvas-editor__canvas-pick-hint">
请选择画布中的图片作为常规参考图,按 Esc 退出
</div>
) : null}
{isPickingGenerationReferenceFromCanvas ? (
<div className="image-canvas-editor__canvas-pick-hint">
请选择画布中的图片作为参考图,按 Esc 退出
</div>
) : null}
{isPickingQuickEditReferenceFromCanvas ? (
<div className="image-canvas-editor__canvas-pick-hint">
请选择画布中的图片作为参考图,按 Esc 退出
</div>
) : null}
{isPickingPublicationReferenceFromCanvas ? (
<div className="image-canvas-editor__canvas-pick-hint">
请选择画布中的图片作为宣发素材参考图,按 Esc 退出
</div>
) : null}
{isPickingIconSpecFromCanvas ? (
<div className="image-canvas-editor__canvas-pick-hint">
请选择画布中的图标规范,按 Esc 退出
</div>
) : null}
{isPickingUiDesignSpecFromCanvas ? (
<div className="image-canvas-editor__canvas-pick-hint">
请选择画布中的图标规范,按 Esc 退出
</div>
) : null}
{quickEditPanel && quickEditSourceLayer && quickEditPanelStyle ? (
<ImageCanvasQuickEditPanelView
panel={quickEditPanel}
style={quickEditPanelStyle}
referenceButtonRef={generationReferenceButtonRef}
isReferenceMenuOpen={isGenerationReferenceMenuOpen}
setIsReferenceMenuOpen={setIsGenerationReferenceMenuOpen}
setIsPickingQuickEditReferenceFromCanvas={
setIsPickingQuickEditReferenceFromCanvas
}
renderEditorPortal={renderEditorPortal}
buildPortalMenuStyle={buildPortalMenuStyle}
onRequestUpload={onRequestUpload}
onRememberImageModel={onRememberImageModel}
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
setQuickEditPanel={setQuickEditPanel}
onSubmit={onSubmitQuickEdit}
/>
) : null}
{cropExpandPanel && cropExpandSourceLayer && cropExpandPanelStyle ? (
<ImageCanvasCropExpandPanelView
panel={cropExpandPanel}
style={cropExpandPanelStyle}
setCropExpandPanel={setCropExpandPanel}
onSubmit={onSubmitCropExpand}
/>
) : null}
{characterAnimationPanel &&
characterAnimationSourceLayer &&
characterAnimationPanelStyle ? (
<ImageCanvasCharacterAnimationPanelView
panel={characterAnimationPanel}
style={characterAnimationPanelStyle}
price={characterAnimationPrice}
sourceLayer={characterAnimationSourceLayer}
setCharacterAnimationPanel={setCharacterAnimationPanel}
onUpdateDuration={onUpdateCharacterAnimationDuration}
onSubmit={onSubmitCharacterAnimation}
/>
) : null}
<ImageCanvasEditGenerationModalView
dialog={generateDialog}
setGenerateDialog={setGenerateDialog}
onSubmit={onSubmitImageGeneration}
/>
</>
);
}