Files
Genarrative/src/components/image-editor/ImageCanvasGenerationComposerView.tsx
T
JenkenB 9c7b3ad16d Image editor: Seedance2.0 refs and audio changes
Adds Seedance 2.0 reference-media constraints and forbids video data URLs; updates docs/decision-log and pitfalls to record the new rules. Introduces typed audio params (type: one-shot|loop, tempo: number|null), generator-specific placeholder styles/avoidance and UI changes for generation panels. Implements related front-end changes (new crop/ raster edit components, many image-editor models, tests and workflow hooks) and server-side contract/handler updates across api-server, shared-contracts, platform-audio, platform-image and vector_engine_audio_generation to support the new submission/asset flow and validations.
2026-06-18 17:31:35 +08:00

1157 lines
43 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 {
calculateEditorVideoPrice,
EDITOR_GENERATION_MUD_POINT_CONFIG,
EDITOR_VIDEO_DURATION_OPTIONS,
EDITOR_VIDEO_MODEL_OPTIONS,
SPEC_TYPE_LABEL,
} from './ImageCanvasGenerationModel';
import { ImageCanvasIconSpritesheetComposerView } from './ImageCanvasIconSpritesheetComposerView';
import { ImageCanvasQuickEditPanelView } from './ImageCanvasQuickEditPanelView';
import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
import { ImageCanvasSpecGenerationPanelView } from './ImageCanvasSpecGenerationPanelView';
import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss';
type ImageCanvasGenerationComposerViewProps = {
specToolWrapRef: RefObject<HTMLSpanElement | null>;
characterSpecButtonRef: RefObject<HTMLButtonElement | null>;
characterReferenceButtonRef: RefObject<HTMLButtonElement | null>;
generationReferenceButtonRef: RefObject<HTMLButtonElement | null>;
iconSpecButtonRef: RefObject<HTMLButtonElement | null>;
isSpecMenuOpen: boolean;
isGenerationReferenceMenuOpen: boolean;
isCharacterSpecMenuOpen: boolean;
isCharacterReferenceMenuOpen: boolean;
isIconSpecMenuOpen: boolean;
isUiDesignSpecMenuOpen: boolean;
isPickingGenerationReferenceFromCanvas: 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;
quickEditSizeOptions: string[];
quickEditModelOptions: Array<{ label: string; value: string }>;
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>>;
setIsCharacterSpecMenuOpen: Dispatch<SetStateAction<boolean>>;
setIsCharacterReferenceMenuOpen: Dispatch<SetStateAction<boolean>>;
setIsIconSpecMenuOpen: Dispatch<SetStateAction<boolean>>;
setIsUiDesignSpecMenuOpen: Dispatch<SetStateAction<boolean>>;
setIsPickingGenerationReferenceFromCanvas: 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;
onCloseGenerateComposer: () => 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 };
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}
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 resolution = dialog.videoResolution ?? '480p';
const durationSeconds = dialog.videoDurationSeconds ?? 4;
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(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'
? {
...resetFailedVideoDialogStatus(currentDialog),
...patch,
}
: currentDialog,
);
};
const updateVideoModel = (model: GenerateDialogState['videoModel']) => {
updateVideoDialog({
videoModel: model,
...(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={`视频参数 16:9 · ${durationSeconds}秒 · ${resolution}`}
aria-expanded={openPanel === 'params'}
disabled={isGenerating}
trailingIcon={<ChevronDown className="h-3 w-3" />}
onClick={() =>
setOpenPanel((currentPanel) =>
currentPanel === 'params' ? null : 'params',
)
}
>
16:9 · {durationSeconds} · {resolution}
</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">
<VideoOptionChoice
selected
disabled={isGenerating}
className="image-canvas-editor__option-popover-choice--ratio"
ariaLabel="比例 16:9"
>
<span
className="image-canvas-editor__ratio-wireframe"
data-ratio="16:9"
aria-hidden="true"
/>
<span>16:9</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__option-popover-items">
{EDITOR_VIDEO_DURATION_OPTIONS.map((option) => {
const nextDuration =
Number(option.value) === 5 ? 5 : 4;
return (
<VideoOptionChoice
key={option.value}
selected={durationSeconds === nextDuration}
disabled={isGenerating}
ariaLabel={`时长 ${option.label}`}
onClick={() =>
updateVideoDialog({
videoDurationSeconds: nextDuration,
})
}
>
{option.label}
</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__option-popover-items">
{(['480p', '720p'] as const).map((option) => (
<VideoOptionChoice
key={option}
selected={resolution === option}
disabled={isGenerating}
ariaLabel={`清晰度 ${option}`}
onClick={() =>
updateVideoDialog({ videoResolution: option })
}
>
{option}
</VideoOptionChoice>
))}
</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}
</>
);
}
const SOUND_TYPE_OPTIONS = [
{ label: '单次', value: 'one-shot' },
{ label: '循环', value: 'loop' },
] as const;
const SOUND_BPM_MIN = 1;
const SOUND_BPM_MAX = 300;
const SOUND_BPM_SLIDER_FALLBACK = 120;
function clampSoundBpm(value: number) {
return Math.min(
SOUND_BPM_MAX,
Math.max(SOUND_BPM_MIN, Math.round(value)),
);
}
function parseSoundBpmInput(value: string) {
const normalizedValue = value.trim();
if (!normalizedValue) {
return null;
}
const parsedValue = Number(normalizedValue);
return Number.isFinite(parsedValue) ? clampSoundBpm(parsedValue) : null;
}
function formatSoundEffectOptionLabel(
typeLabel: string,
soundTempo: number | null | undefined,
) {
return soundTempo ? `${typeLabel}·${soundTempo}BPM` : typeLabel;
}
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 isSoundEffect = dialog.mode === 'audio-sound-effect';
const isGenerating = dialog.status === 'generating';
const currentType =
SOUND_TYPE_OPTIONS.find((option) => option.value === dialog.soundType) ??
SOUND_TYPE_OPTIONS[0];
const currentTempo =
typeof dialog.soundTempo === 'number' ? dialog.soundTempo : null;
const soundOptionLabel = formatSoundEffectOptionLabel(
currentType.label,
currentTempo,
);
const dialogLabel = isSoundEffect ? '生成游戏音效' : '生成游戏背景音乐';
const cost = isSoundEffect
? EDITOR_GENERATION_MUD_POINT_CONFIG.soundEffect
: EDITOR_GENERATION_MUD_POINT_CONFIG.backgroundMusic;
useImageCanvasFloatingOptionDismiss({
isOpen: isSoundOptionsOpen,
boundaryRefs: [soundOptionsButtonRef],
onDismiss: () => setIsSoundOptionsOpen(false),
});
const updateAudioDialog = (patch: Partial<GenerateDialogState>) => {
setGenerateDialog((currentDialog) =>
currentDialog?.mode === dialog.mode
? {
...resetFailedAudioDialogStatus(currentDialog),
...patch,
}
: currentDialog,
);
};
const updateSoundTempo = (nextTempo: number | null) => {
updateAudioDialog({ soundTempo: nextTempo });
};
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 ? 'sound提示词' : '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-items image-canvas-editor__audio-type-options">
{SOUND_TYPE_OPTIONS.map((option) => (
<VideoOptionChoice
key={option.value}
selected={currentType.value === option.value}
disabled={isGenerating}
ariaLabel={option.label}
onClick={() =>
updateAudioDialog({ soundType: option.value })
}
>
{option.label}
</VideoOptionChoice>
))}
</div>
<div className="image-canvas-editor__audio-bpm-control">
<input
type="range"
min={SOUND_BPM_MIN}
max={SOUND_BPM_MAX}
value={currentTempo ?? SOUND_BPM_SLIDER_FALLBACK}
disabled={isGenerating}
aria-label="BPM拖拉条"
onChange={(event) =>
updateSoundTempo(
clampSoundBpm(Number(event.target.value)),
)
}
/>
<input
type="number"
min={SOUND_BPM_MIN}
max={SOUND_BPM_MAX}
value={currentTempo ?? ''}
placeholder="BPM"
disabled={isGenerating}
aria-label="BPM数值"
onChange={(event) =>
updateSoundTempo(
parseSoundBpmInput(event.target.value),
)
}
/>
</div>
</PlatformFloatingMenu>,
)
: null}
</div>
) : null}
<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,
characterSpecButtonRef,
characterReferenceButtonRef,
generationReferenceButtonRef,
iconSpecButtonRef,
isSpecMenuOpen,
isGenerationReferenceMenuOpen,
isCharacterSpecMenuOpen,
isCharacterReferenceMenuOpen,
isIconSpecMenuOpen,
isUiDesignSpecMenuOpen,
isPickingGenerationReferenceFromCanvas,
isPickingCharacterSpecFromCanvas,
isPickingCharacterReferenceFromCanvas,
isPickingIconSpecFromCanvas,
isPickingUiDesignSpecFromCanvas,
generateDialog,
generationComposerStyle,
iconComposerStyle,
quickEditPanel,
quickEditSourceLayer,
quickEditPanelStyle,
cropExpandPanel,
cropExpandSourceLayer,
cropExpandPanelStyle,
quickEditSizeOptions,
quickEditModelOptions,
characterAnimationPanel,
characterAnimationSourceLayer,
characterAnimationPanelStyle,
characterAnimationPrice,
setGenerateDialog,
setQuickEditPanel,
setCropExpandPanel,
setCharacterAnimationPanel,
setIsGenerationReferenceMenuOpen,
setIsCharacterSpecMenuOpen,
setIsCharacterReferenceMenuOpen,
setIsIconSpecMenuOpen,
setIsUiDesignSpecMenuOpen,
setIsPickingGenerationReferenceFromCanvas,
setIsPickingCharacterSpecFromCanvas,
setIsPickingCharacterReferenceFromCanvas,
setIsPickingIconSpecFromCanvas,
setIsPickingUiDesignSpecFromCanvas,
onOpenSpecDialog,
onRequestUpload,
onSubmitImageGeneration,
onSubmitIconSpritesheetGeneration,
onSubmitQuickEdit,
onSubmitCropExpand,
onSubmitCharacterAnimation,
onCloseGenerateComposer,
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.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}
onClose={onCloseGenerateComposer}
/>
) : 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 === '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}
{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 &&
(quickEditPanel.mode === 'redraw' ||
quickEditPanel.status !== 'generating') &&
quickEditSourceLayer &&
quickEditPanelStyle ? (
<ImageCanvasQuickEditPanelView
panel={quickEditPanel}
sourceLayer={quickEditSourceLayer}
style={quickEditPanelStyle}
sizeOptions={quickEditSizeOptions}
modelOptions={quickEditModelOptions}
setQuickEditPanel={setQuickEditPanel}
onSubmit={onSubmitQuickEdit}
/>
) : null}
{cropExpandPanel &&
cropExpandSourceLayer &&
cropExpandPanelStyle ? (
<ImageCanvasCropExpandPanelView
panel={cropExpandPanel}
sourceLayer={cropExpandSourceLayer}
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}
/>
</>
);
}