import { ChevronDown, ImageIcon, X } from 'lucide-react'; import { type CSSProperties, type Dispatch, type ReactNode, type SetStateAction, useCallback, useRef, useState, } from 'react'; import type { EditorCharacterAnimationRatio, EditorCharacterAnimationResolution, } from '../../services/image-editor/editorProjectClient'; import { AutoGrowTextArea } from '../common/AutoGrowTextArea'; import { PlatformActionButton } from '../common/PlatformActionButton'; import { PlatformFloatingMenu } from '../common/PlatformFloatingMenu'; import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { ImageCanvasEditorPortal } from './ImageCanvasEditorPortal'; import { EditorIconButton } from './ImageCanvasEditorPrimitives'; import type { CanvasLayer, CharacterAnimationPanelState, } from './ImageCanvasEditorTypes'; import { CHARACTER_ANIMATION_ACTION_PROMPTS, CHARACTER_ANIMATION_DURATION_OPTIONS, CHARACTER_ANIMATION_RATIO_OPTIONS, } from './ImageCanvasGenerationModel'; import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot'; import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss'; type ImageCanvasCharacterAnimationPanelViewProps = { panel: CharacterAnimationPanelState; sourceLayer: CanvasLayer; style: CSSProperties; price: number; setCharacterAnimationPanel: Dispatch< SetStateAction >; onUpdateDuration: (frameCountValue: string) => void; onSubmit: () => void; /** * 比例 / 时长 / 清晰度入口是否渲染,默认 `true`,与美术画布一致。 * * 传 `false` 的宿主必须自己说清这三个值由谁决定:它们不在该宿主的提交载荷里时, * 渲染出来就是点了不生效的假控件(改了 1080p 实际仍按固定档出图)。 */ showParameters?: boolean; }; function resetFailedPanelStatus< T extends { status: string; errorMessage?: string }, >(panel: T) { return { ...panel, status: panel.status === 'failed' ? 'idle' : panel.status, errorMessage: panel.status === 'failed' ? undefined : panel.errorMessage, }; } function buildLocalMenuStyle(anchor: HTMLElement | null): 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: Math.round(rect.top), right: 'auto', bottom: 'auto', zIndex: 70, transform: 'translateY(calc(-100% - 0.45rem))', }; } function renderPanelPortal(node: ReactNode) { // 中文注释:参数菜单挂到页面级,避免被角色动画面板滚动边界裁切。 return {node}; } function getRatioLabel(value: EditorCharacterAnimationRatio) { return value === 'same' ? '同图尺寸' : value; } function AnimationOptionChoice({ children, selected, className, ariaLabel, disabled, onClick, }: { children: ReactNode; selected: boolean; className?: string; ariaLabel?: string; disabled?: boolean; onClick?: () => void; }) { return ( ); } export function ImageCanvasCharacterAnimationPanelView({ panel, sourceLayer, style, price, setCharacterAnimationPanel, onUpdateDuration, onSubmit, showParameters = true, }: ImageCanvasCharacterAnimationPanelViewProps) { const [isParameterMenuOpen, setIsParameterMenuOpen] = useState(false); const parameterButtonRef = useRef(null); const isGenerating = panel.status === 'generating'; const ratioLabel = getRatioLabel(panel.ratio); const dismissParameterMenu = useCallback(() => { setIsParameterMenuOpen(false); }, []); useImageCanvasFloatingOptionDismiss({ isOpen: isParameterMenuOpen, boundaryRefs: [parameterButtonRef], onDismiss: dismissParameterMenu, }); const updatePanel = (patch: Partial) => { setCharacterAnimationPanel((currentPanel) => currentPanel ? { ...resetFailedPanelStatus(currentPanel), ...patch, } : currentPanel, ); }; const closePanel = () => { setCharacterAnimationPanel((currentPanel) => currentPanel && isGenerating ? currentPanel : null, ); }; return ( <>
event.stopPropagation()} >
updatePanel({ promptText: value })} />
{CHARACTER_ANIMATION_ACTION_PROMPTS.map((preset) => ( ))}
{showParameters ? (
} onClick={() => setIsParameterMenuOpen((open) => !open)} > {ratioLabel} · {panel.durationSeconds}秒 · {panel.resolution}
) : null} { if (!isGenerating) { onSubmit(); } }} > {isGenerating ? '生成中' : `生成${price}泥点`}
{panel.promptText.trim() ? panel.promptText.trim() : '动画描述'} {panel.status === 'completed' && panel.result ? ( 已生成 {panel.result.frameCount} 帧 ) : null} {panel.status === 'failed' ? ( {panel.errorMessage} ) : null}
{showParameters && isParameterMenuOpen ? renderPanelPortal( event.stopPropagation()} >
比例
{CHARACTER_ANIMATION_RATIO_OPTIONS.map((option) => ( updatePanel({ ratio: option.value })} > ))}
时长
{CHARACTER_ANIMATION_DURATION_OPTIONS.map((option) => ( onUpdateDuration(String(option.frameCount)) } > {option.label} ))}
清晰度
{( ['480p', '720p'] as EditorCharacterAnimationResolution[] ).map((resolution) => ( updatePanel({ resolution })} > {resolution} ))}
, ) : null} ); }