3508957eda
- 新增 ImageCanvasProjectAssetPickerDialog:独立弹窗,网格卡片、多选、筛选+搜索、清空/确认/取消,筛选状态自持 - 新增 projectAssetReferencePickerModel:素材分类与筛选判据、素材 id 快照引用构造、弹窗引用前缀判定 - 生成 composer 参考图来源菜单新增「从项目素材中选择」,由宿主决定是否出现,不改变原有从画布选择与上传两条路径 - 新增 replaceProjectAssetPickerReferences:确认时整组替换素材库来源的参考图,保留画布点选与上传的参考图 - 素材 id 经 sourceAssetId 走既有 createGenerationInputReference 落进 generationInputs.references,不新增后端接口 - 新增 13 条测试覆盖弹窗交互、筛选叠加、快照落库与整组替换语义
1850 lines
71 KiB
TypeScript
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}
|
|
/>
|
|
</>
|
|
);
|
|
}
|