Files
Genarrative/src/components/image-editor/ImageCanvasGenerationComposerView.tsx
T
kdletters 9807295a7d 调整画布生成输入与图集快速编辑
隐藏生成面板资源名称字段并恢复提示词输入边框

恢复图标图集快速编辑并仅对单图标禁用

补充工具栏右键菜单工作流回归测试与编辑器文档
2026-07-14 23:26:35 +08:00

1254 lines
48 KiB
TypeScript

import { Check, ChevronDown, Cpu, Film, ImageIcon, Music } from 'lucide-react';
import {
type CSSProperties,
type Dispatch,
type ReactNode,
type RefObject,
type SetStateAction,
useCallback,
useRef,
useState,
} from 'react';
import { createPortal } from 'react-dom';
import { PlatformActionButton } from '../common/PlatformActionButton';
import {
PlatformFloatingMenu,
PlatformFloatingMenuItem,
} from '../common/PlatformFloatingMenu';
import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformTextField } from '../common/PlatformTextField';
import { ImageCanvasBasicGenerationComposerView } from './ImageCanvasBasicGenerationComposerView';
import { ImageCanvasCharacterAnimationPanelView } from './ImageCanvasCharacterAnimationPanelView';
import { ImageCanvasCharacterGenerationComposerView } from './ImageCanvasCharacterGenerationComposerView';
import { ImageCanvasCropExpandPanelView } from './ImageCanvasCropExpandPanelView';
import { ImageCanvasEditGenerationModalView } from './ImageCanvasEditGenerationModalView';
import type {
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,
DEFAULT_VIDEO_WEB_SEARCH_ENABLED,
EDITOR_VIDEO_ASPECT_RATIO_OPTIONS,
EDITOR_SOUND_EFFECT_MODEL_OPTIONS,
EDITOR_VIDEO_MODEL_OPTIONS,
EDITOR_VIDEO_RESOLUTION_OPTIONS,
VIDEO_MODEL_SEEDANCE_2_FAST,
resizeGenerationPlaceholderToVideoSelection,
SPEC_TYPE_LABEL,
} from './ImageCanvasGenerationModel';
import { ImageCanvasIconSpritesheetComposerView } from './ImageCanvasIconSpritesheetComposerView';
import { ImageCanvasPublicationMaterialsDemoPanelView } from './ImageCanvasPublicationMaterialsDemoPanelView';
import { getPublicationMaterialsWorkflow } from './ImageCanvasPublicationMaterialsModel';
import { ImageCanvasQuickEditPanelView } from './ImageCanvasQuickEditPanelView';
import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
import { ImageCanvasSpecGenerationPanelView } from './ImageCanvasSpecGenerationPanelView';
import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss';
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;
generateDialog: GenerateDialogState | null;
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;
onSubmitImageGeneration: (dialog: GenerateDialogState) => void;
onSubmitIconSpritesheetGeneration: (dialog: GenerateDialogState) => void;
onSubmitQuickEdit: () => void;
onSubmitCropExpand: () => void;
onSubmitCharacterAnimation: () => 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 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) {
if (typeof document === 'undefined') {
return node;
}
return createPortal(node, document.body);
}
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 (
<>
<form
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()}
onSubmit={(event) => {
event.preventDefault();
if (!isGenerating) {
onSubmit(dialog);
}
}}
>
{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}
<PlatformTextField
variant="textarea"
aria-label="视频描述"
value={dialog.prompt}
disabled={isGenerating}
placeholder="你希望生成什么视频?"
size="sm"
density="compact"
className="image-canvas-editor__generation-prompt"
onChange={(event) =>
updateVideoDialog({ prompt: event.target.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="submit"
tone="secondary"
size="xs"
shape="pill"
className="image-canvas-editor__generation-submit"
disabled={isGenerating}
aria-label="生成视频"
>
{isGenerating ? (
'生成中'
) : (
<>
<span>生成</span>
<span className="image-canvas-editor__mud-point-inline">
{price}泥点
</span>
</>
)}
</PlatformActionButton>
</div>
</form>
{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(10, Math.max(2, Math.round(value)));
}
function resetFailedAudioDialogStatus(dialog: GenerateDialogState) {
return {
...dialog,
status: dialog.status === 'failed' ? 'idle' : dialog.status,
errorMessage: dialog.status === 'failed' ? undefined : dialog.errorMessage,
};
}
function ImageCanvasAudioGenerationComposerView({
dialog,
style,
setGenerateDialog,
renderEditorPortal,
buildPortalMenuStyle,
onSubmit,
}: {
dialog: GenerateDialogState;
style: CSSProperties;
setGenerateDialog: Dispatch<SetStateAction<GenerateDialogState | null>>;
renderEditorPortal: (node: ReactNode) => ReactNode;
buildPortalMenuStyle: (
anchor: HTMLElement | null,
placement: 'above' | 'below',
) => CSSProperties;
onSubmit: (dialog: GenerateDialogState) => void;
}) {
const [isSoundOptionsOpen, setIsSoundOptionsOpen] = useState(false);
const soundOptionsButtonRef = useRef<HTMLButtonElement | null>(null);
const soundModelButtonRef = useRef<HTMLButtonElement | null>(null);
const isSoundEffect = dialog.mode === 'audio-sound-effect';
const isGenerating = dialog.status === 'generating';
const soundModel = dialog.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(dialog.soundDurationSeconds);
const soundOptionLabel = `${soundDuration}秒`;
const dialogLabel = isSoundEffect ? '生成游戏音效' : '生成游戏背景音乐';
const fixedModelLabel = isSoundEffect ? currentSoundModel.label : 'Suno';
const fixedModelAriaLabel = isSoundEffect
? `音效模型 ${fixedModelLabel}`
: `背景音乐模型 ${fixedModelLabel}`;
const cost = isSoundEffect
? calculateEditorSoundEffectPrice(soundModel)
: calculateEditorBackgroundMusicPrice(DEFAULT_BACKGROUND_MUSIC_MODEL);
useImageCanvasFloatingOptionDismiss({
isOpen: isSoundOptionsOpen,
boundaryRefs: [soundOptionsButtonRef],
onDismiss: () => setIsSoundOptionsOpen(false),
});
const updateAudioDialog = (patch: Partial<GenerateDialogState>) => {
setGenerateDialog((currentDialog) =>
currentDialog?.mode === dialog.mode
? {
...resetFailedAudioDialogStatus(currentDialog),
...patch,
}
: currentDialog,
);
};
const updateSoundDuration = (durationSeconds: number) => {
updateAudioDialog({ soundDurationSeconds: normalizeSoundDuration(durationSeconds) });
};
return (
<form
className="image-canvas-editor__generation-composer image-canvas-editor__generation-composer--image image-canvas-editor__generation-composer--audio"
style={style}
role="dialog"
aria-label={dialogLabel}
onPointerDown={(event) => event.stopPropagation()}
onSubmit={(event) => {
event.preventDefault();
if (!isGenerating) {
onSubmit(dialog);
}
}}
>
<PlatformTextField
variant="textarea"
aria-label={isSoundEffect ? 'prompt' : 'gpt_description_prompt'}
value={dialog.prompt}
disabled={isGenerating}
placeholder={
isSoundEffect
? '你希望生成什么音效?'
: '你希望生成什么音乐?'
}
size="sm"
density="compact"
className="image-canvas-editor__generation-prompt"
onChange={(event) => updateAudioDialog({ prompt: event.target.value })}
/>
{dialog.status === 'failed' ? (
<PlatformStatusMessage
tone="error"
surface="platform"
size="xs"
className="image-canvas-editor__generate-status"
role="alert"
>
{dialog.errorMessage}
</PlatformStatusMessage>
) : null}
<div className="image-canvas-editor__generation-composer-footer">
{isSoundEffect ? (
<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={isGenerating}
trailingIcon={<ChevronDown className="h-3 w-3" />}
onClick={() => setIsSoundOptionsOpen((isOpen) => !isOpen)}
>
{soundOptionLabel}
</PlatformInlineOptionButton>
{isSoundOptionsOpen
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__option-popover image-canvas-editor__option-popover--audio-options image-canvas-editor__portal-menu"
label="音效时长选项"
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>
<div className="image-canvas-editor__video-duration-slider">
<input
type="range"
min={2}
max={10}
step={1}
value={soundDuration}
disabled={isGenerating}
aria-label="音效时长"
onChange={(event) =>
updateSoundDuration(Number(event.target.value))
}
/>
<span>{soundDuration}</span>
</div>
</section>
</div>
</PlatformFloatingMenu>,
)
: null}
</div>
) : null}
<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="submit"
tone="secondary"
size="xs"
shape="pill"
className="image-canvas-editor__generation-submit"
disabled={isGenerating}
aria-label={dialogLabel}
>
{isGenerating ? (
'生成中'
) : (
<>
<span>生成</span>
<span className="image-canvas-editor__mud-point-inline">
{cost}泥点
</span>
</>
)}
</PlatformActionButton>
</div>
</form>
);
}
export function ImageCanvasGenerationComposerView({
specToolWrapRef,
publicationReferenceButtonRef,
characterSpecButtonRef,
characterReferenceButtonRef,
generationReferenceButtonRef,
iconSpecButtonRef,
isSpecMenuOpen,
isGenerationReferenceMenuOpen,
isPublicationReferenceMenuOpen,
isCharacterSpecMenuOpen,
isCharacterReferenceMenuOpen,
isIconSpecMenuOpen,
isUiDesignSpecMenuOpen,
isPickingGenerationReferenceFromCanvas,
isPickingQuickEditReferenceFromCanvas,
isPickingPublicationReferenceFromCanvas,
isPickingCharacterSpecFromCanvas,
isPickingCharacterReferenceFromCanvas,
isPickingIconSpecFromCanvas,
isPickingUiDesignSpecFromCanvas,
generateDialog,
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,
onOpenSpecDialog,
onRequestUpload,
onSubmitImageGeneration,
onSubmitIconSpritesheetGeneration,
onSubmitQuickEdit,
onSubmitCropExpand,
onSubmitCharacterAnimation,
onUpdateSpecFormValue,
onUpdateIconDescriptionText,
onUpdateCharacterAnimationDuration,
onRememberImageModel,
onSpecMenuPointerEnter,
onSpecMenuPointerLeave,
}: ImageCanvasGenerationComposerViewProps) {
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', 'ui', '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}
{(generateDialog?.mode === 'generate' ||
generateDialog?.mode === 'quick-edit') &&
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}
onToggleReferenceMenu={() =>
setIsGenerationReferenceMenuOpen((open) => !open)
}
onRememberImageModel={onRememberImageModel}
onSubmit={onSubmitImageGeneration}
/>
) : 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}
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}
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}
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={generateDialog}
style={generationComposerStyle}
setGenerateDialog={setGenerateDialog}
renderEditorPortal={renderEditorPortal}
buildPortalMenuStyle={buildPortalMenuStyle}
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}
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}
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}
sourceLayer={quickEditSourceLayer}
style={quickEditPanelStyle}
referenceButtonRef={generationReferenceButtonRef}
isReferenceMenuOpen={isGenerationReferenceMenuOpen}
setIsReferenceMenuOpen={setIsGenerationReferenceMenuOpen}
setIsPickingQuickEditReferenceFromCanvas={
setIsPickingQuickEditReferenceFromCanvas
}
renderEditorPortal={renderEditorPortal}
buildPortalMenuStyle={buildPortalMenuStyle}
onRequestUpload={onRequestUpload}
onRememberImageModel={onRememberImageModel}
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}
/>
</>
);
}