forked from 77media/video-flow
1364 lines
50 KiB
TypeScript
1364 lines
50 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useRef, useEffect } from "react";
|
||
import {
|
||
ChevronDown,
|
||
ChevronUp,
|
||
Video,
|
||
Loader2,
|
||
Lightbulb,
|
||
Package,
|
||
Crown,
|
||
Clapperboard,
|
||
Globe,
|
||
AudioLines,
|
||
Clock,
|
||
Trash2,
|
||
Plus,
|
||
LayoutTemplate,
|
||
ImagePlay,
|
||
Sparkles,
|
||
RotateCcw,
|
||
Settings,
|
||
} from "lucide-react";
|
||
import { Dropdown, Modal, Tooltip, Upload, Image, Spin } from "antd";
|
||
import { PlusOutlined, UploadOutlined, EyeOutlined } from "@ant-design/icons";
|
||
import { StoryTemplateEntity } from "@/app/service/domain/Entities";
|
||
import { useImageStoryServiceHook } from "@/app/service/Interaction/ImageStoryService";
|
||
import TemplateCard from "./templateCard";
|
||
import { AudioRecorder } from "./AudioRecorder";
|
||
import { useTemplateStoryServiceHook } from "@/app/service/Interaction/templateStoryService";
|
||
import { createScriptEpisodeNew } from "@/api/script_episode";
|
||
import { useRouter } from "next/navigation";
|
||
import { EditorContent, useEditor } from "@tiptap/react";
|
||
import StarterKit from "@tiptap/starter-kit";
|
||
import { HighlightTextExtension } from "@/components/ui/main-editor/HighlightText";
|
||
import Placeholder from "@tiptap/extension-placeholder";
|
||
import { createMovieProjectV1 } from "@/api/video_flow";
|
||
import { useLoadScriptText, useUploadFile } from "@/app/service/domain/service";
|
||
|
||
// 自定义音频播放器样式
|
||
const customAudioPlayerStyles = `
|
||
.custom-audio-player {
|
||
background: rgba(255, 255, 255, 0.05) !important;
|
||
border-radius: 8px !important;
|
||
border: 1px solid rgba(255, 255, 255, 0.1) !important;
|
||
}
|
||
.custom-audio-player .rhap_main-controls-button {
|
||
color: white !important;
|
||
}
|
||
.custom-audio-player .rhap_progress-filled {
|
||
background-color: #3b82f6 !important;
|
||
}
|
||
.custom-audio-player .rhap_progress-indicator {
|
||
background-color: #3b82f6 !important;
|
||
}
|
||
.custom-audio-player .rhap_time {
|
||
color: rgba(255, 255, 255, 0.7) !important;
|
||
}
|
||
.custom-audio-player .rhap_volume-controls {
|
||
color: white !important;
|
||
}
|
||
|
||
/* 模式选择下拉菜单样式 */
|
||
.mode-dropdown .ant-dropdown-menu {
|
||
background: rgba(255, 255, 255, 0.08) !important;
|
||
backdrop-filter: blur(20px) !important;
|
||
border: 1px solid rgba(255, 255, 255, 0.12) !important;
|
||
border-radius: 10px !important;
|
||
padding: 6px !important;
|
||
min-width: 160px !important;
|
||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3) !important;
|
||
}
|
||
|
||
.mode-dropdown .ant-dropdown-menu-item {
|
||
padding: 6px 10px !important;
|
||
border-radius: 6px !important;
|
||
color: rgba(255, 255, 255, 0.9) !important;
|
||
transition: all 0.2s ease !important;
|
||
margin-bottom: 3px !important;
|
||
}
|
||
|
||
.mode-dropdown .ant-dropdown-menu-item:hover {
|
||
background: rgba(255, 255, 255, 0.15) !important;
|
||
transform: translateX(4px) !important;
|
||
}
|
||
|
||
.mode-dropdown .ant-dropdown-menu-item:last-child {
|
||
margin-bottom: 0 !important;
|
||
}
|
||
|
||
/* 模式提示tooltip样式 */
|
||
.mode-tooltip .ant-tooltip-inner {
|
||
background: rgba(0, 0, 0, 0.8) !important;
|
||
backdrop-filter: blur(10px) !important;
|
||
border: 1px solid rgba(255, 255, 255, 0.1) !important;
|
||
border-radius: 8px !important;
|
||
color: white !important;
|
||
font-size: 12px !important;
|
||
line-height: 1.4 !important;
|
||
max-width: 200px !important;
|
||
}
|
||
|
||
.mode-tooltip .ant-tooltip-arrow::before {
|
||
background: rgba(0, 0, 0, 0.8) !important;
|
||
border: 1px solid rgba(255, 255, 255, 0.1) !important;
|
||
}
|
||
|
||
/* 模板卡片样式 */
|
||
.line-clamp-2 {
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 2;
|
||
-webkit-box-orient: vertical;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* 自定义滚动条 */
|
||
.template-list-scroll::-webkit-scrollbar {
|
||
width: 4px;
|
||
}
|
||
|
||
.template-list-scroll::-webkit-scrollbar-track {
|
||
background: rgba(255, 255, 255, 0.05);
|
||
border-radius: 2px;
|
||
}
|
||
|
||
.template-list-scroll::-webkit-scrollbar-thumb {
|
||
background: rgba(255, 255, 255, 0.2);
|
||
border-radius: 2px;
|
||
}
|
||
|
||
.template-list-scroll::-webkit-scrollbar-thumb:hover {
|
||
background: rgba(255, 255, 255, 0.3);
|
||
}
|
||
|
||
/* 自定义缩放类 */
|
||
.scale-102 {
|
||
transform: scale(1.02);
|
||
}
|
||
|
||
/* 文本截断类 */
|
||
.line-clamp-3 {
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 3;
|
||
-webkit-box-orient: vertical;
|
||
overflow: hidden;
|
||
}
|
||
`;
|
||
|
||
/**模板故事模式弹窗组件 */
|
||
const RenderTemplateStoryMode = ({
|
||
isOpen,
|
||
onClose,
|
||
}: {
|
||
isOpen: boolean;
|
||
onClose: () => void;
|
||
}) => {
|
||
// 使用 hook 管理状态
|
||
const {
|
||
templateStoryList,
|
||
selectedTemplate,
|
||
activeRoleIndex,
|
||
activeRole,
|
||
isLoading,
|
||
getTemplateStoryList,
|
||
actionStory,
|
||
setSelectedTemplate,
|
||
setActiveRoleIndex,
|
||
setActiveRoleImage,
|
||
setActiveRoleAudio,
|
||
} = useTemplateStoryServiceHook();
|
||
|
||
// 本地加载状态,用于 UI 反馈
|
||
const [localLoading, setLocalLoading] = useState(false);
|
||
|
||
// 组件挂载时获取模板列表
|
||
useEffect(() => {
|
||
if (isOpen) {
|
||
getTemplateStoryList();
|
||
}
|
||
}, [isOpen, getTemplateStoryList]);
|
||
|
||
// 处理模板选择
|
||
const handleTemplateSelect = (template: StoryTemplateEntity) => {
|
||
setSelectedTemplate(template);
|
||
setActiveRoleIndex(0); // 重置角色选择
|
||
};
|
||
|
||
// 处理确认操作
|
||
const handleConfirm = async () => {
|
||
if (!selectedTemplate) return;
|
||
|
||
try {
|
||
setLocalLoading(true);
|
||
const projectId = await actionStory();
|
||
console.log("Story action created:", projectId);
|
||
onClose();
|
||
// 重置状态
|
||
setSelectedTemplate(null);
|
||
setActiveRoleIndex(0);
|
||
} catch (error) {
|
||
console.error("Failed to create story action:", error);
|
||
// 这里可以添加 toast 提示
|
||
} finally {
|
||
setLocalLoading(false);
|
||
}
|
||
};
|
||
|
||
// 处理角色图片上传
|
||
const handleRoleImageUpload = (roleIndex: number, file: any) => {
|
||
if (file && selectedTemplate) {
|
||
// 模拟上传成功,设置图片URL
|
||
const imageUrl = URL.createObjectURL(file);
|
||
setActiveRoleImage(imageUrl);
|
||
}
|
||
};
|
||
|
||
// 删除角色图片
|
||
const handleDeleteRoleImage = (roleIndex: number) => {
|
||
if (selectedTemplate) {
|
||
setActiveRoleImage("");
|
||
}
|
||
};
|
||
// 模板列表渲染
|
||
const templateListRender = () => {
|
||
return (
|
||
<div className="w-1/3 p-6 border-r border-white/[0.1]">
|
||
<h3 className="text-xl font-bold text-white mb-6">Story Templates</h3>
|
||
<div className="space-y-4 max-h-[700px] overflow-y-auto pr-3 template-list-scroll">
|
||
{templateStoryList.map((template, index) => (
|
||
<div
|
||
key={template.id}
|
||
data-alt={`template-card-${index}`}
|
||
className="flex justify-center"
|
||
onClick={() => handleTemplateSelect(template)}
|
||
>
|
||
<TemplateCard
|
||
imageUrl={template.imageUrl[0]}
|
||
imageAlt={template.name}
|
||
title={template.name}
|
||
description={template.generateText}
|
||
isSelected={selectedTemplate?.id === template.id}
|
||
width={200}
|
||
height={280}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
// 故事编辑器渲染
|
||
const storyEditorRender = () => {
|
||
return isLoading ? (
|
||
<div className="flex items-center justify-center h-64">
|
||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white"></div>
|
||
</div>
|
||
) : selectedTemplate ? (
|
||
<div>
|
||
{/* 模板信息头部 - 增加顶部空间 */}
|
||
<div className="flex gap-6 p-6 border-b border-white/[0.1]">
|
||
{/* 左侧图片 */}
|
||
<div className="w-1/3">
|
||
<div
|
||
data-alt="template-preview-image"
|
||
className="relative w-full aspect-square rounded-xl overflow-hidden group cursor-pointer"
|
||
>
|
||
<img
|
||
src={selectedTemplate.imageUrl[0]}
|
||
alt={selectedTemplate.name}
|
||
className="w-full h-full object-cover transition-all duration-500 group-hover:scale-105 group-hover:rotate-1"
|
||
/>
|
||
<div className="absolute inset-0 bg-gradient-to-t from-black/40 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||
</div>
|
||
</div>
|
||
|
||
{/* 右侧信息 - 增加文本渲染空间 */}
|
||
<div className="flex-1 flex flex-col">
|
||
<h2
|
||
data-alt="template-title"
|
||
className="text-2xl font-bold text-white mb-6"
|
||
>
|
||
{selectedTemplate.name}
|
||
</h2>
|
||
<div className="flex-1 overflow-y-auto max-h-80 pr-2">
|
||
<p
|
||
data-alt="template-description"
|
||
className="text-gray-300 text-base leading-relaxed"
|
||
>
|
||
{selectedTemplate.generateText}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 角色自定义部分 - 精简布局 */}
|
||
<div className="p-6">
|
||
<h3
|
||
data-alt="roles-section-title"
|
||
className="text-lg font-semibold text-white mb-4"
|
||
>
|
||
Character Customization
|
||
</h3>
|
||
|
||
{/* 紧凑布局 */}
|
||
<div className="mb-6 flex gap-4">
|
||
{/* 左侧:当前选中角色的音频与照片更改 - 精简版本 */}
|
||
<div className="flex-1 space-y-4">
|
||
{/* 图片上传部分 - 精简 */}
|
||
<div className="space-y-2 mb-8">
|
||
<div className="flex justify-center">
|
||
<Tooltip
|
||
title="Upload a portrait photo to replace this character's appearance in the movie."
|
||
placement="top"
|
||
>
|
||
<Upload
|
||
name="avatar"
|
||
listType="picture-card"
|
||
className="avatar-uploader [&_.ant-upload-select]:!w-32 [&_.ant-upload-select]:!h-32"
|
||
showUploadList={false}
|
||
action="https://660d2bd96ddfa2943b33731c.mockapi.io/api/upload"
|
||
beforeUpload={() => false}
|
||
onChange={(info) => {
|
||
if (info.file.status === "done") {
|
||
handleRoleImageUpload(
|
||
activeRoleIndex,
|
||
info.file.originFileObj
|
||
);
|
||
}
|
||
}}
|
||
>
|
||
{activeRole?.photo_url ? (
|
||
<div className="relative w-32 h-32 rounded-lg overflow-hidden">
|
||
<img
|
||
src={activeRole.photo_url}
|
||
alt="Character Portrait"
|
||
className="w-full h-full object-cover"
|
||
/>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleDeleteRoleImage(activeRoleIndex);
|
||
}}
|
||
className="absolute top-1 right-1 w-5 h-5 bg-red-500/80 hover:bg-red-500 text-white rounded-full flex items-center justify-center transition-colors"
|
||
title="Delete Photo"
|
||
>
|
||
<Trash2 className="w-2.5 h-2.5" />
|
||
</button>
|
||
<div className="absolute inset-0 bg-black/50 opacity-0 hover:opacity-100 transition-opacity duration-200 flex items-center justify-center">
|
||
<div className="text-white text-center">
|
||
<UploadOutlined className="w-4 h-4 mb-1" />
|
||
<div className="text-xs">Change Photo</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="w-32 h-32 flex flex-col items-center justify-center text-white/50 bg-white/[0.05] border border-white/[0.1] rounded-lg hover:bg-white/[0.08] transition-colors">
|
||
<UploadOutlined className="w-6 h-6 mb-1" />
|
||
<span className="text-xs">Upload Photo</span>
|
||
</div>
|
||
)}
|
||
</Upload>
|
||
</Tooltip>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 音频部分 - 精简版本 */}
|
||
<div className="space-y-2">
|
||
{/* 音频操作区域 - 使用新的 AudioRecorder 组件 */}
|
||
<div className="space-y-2">
|
||
<AudioRecorder
|
||
audioUrl={activeRole?.voice_url || ""}
|
||
onAudioRecorded={(audioBlob, audioUrl) => {
|
||
setActiveRoleAudio(audioUrl);
|
||
}}
|
||
onAudioDeleted={() => {
|
||
setActiveRoleAudio("");
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 右侧:角色图片缩略图列表 - 精简 */}
|
||
<div className="w-24 space-y-2">
|
||
<h4 className="text-white/70 text-xs font-medium mb-2">
|
||
Characters
|
||
</h4>
|
||
{selectedTemplate.storyRole.map((role, index: number) => (
|
||
<Tooltip key={index} title={role.role_name} placement="left">
|
||
<button
|
||
data-alt={`character-thumbnail-${index}`}
|
||
className={`w-full aspect-square rounded-lg overflow-hidden border-2 transition-all duration-200 hover:scale-105 ${
|
||
activeRoleIndex === index
|
||
? "border-blue-500 shadow-lg shadow-blue-500/30"
|
||
: "border-white/20 hover:border-white/40"
|
||
}`}
|
||
onClick={() => setActiveRoleIndex(index)}
|
||
>
|
||
<img
|
||
src={role.photo_url}
|
||
alt={role.role_name}
|
||
className="w-full h-full object-cover"
|
||
/>
|
||
</button>
|
||
</Tooltip>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 弹窗底部操作 - 只保留 Action 按钮 */}
|
||
<div className="relative group flex justify-end mt-10">
|
||
<div className="relative w-40 h-12 opacity-90 overflow-hidden rounded-xl bg-black z-10">
|
||
<div className="absolute z-10 -translate-x-32 group-hover:translate-x-[20rem] ease-in transistion-all duration-700 h-full w-32 bg-gradient-to-r from-gray-500 to-white/10 opacity-30 -skew-x-12"></div>
|
||
|
||
<div className="absolute flex items-center justify-center text-white z-[1] opacity-90 rounded-2xl inset-0.5 bg-black">
|
||
<button
|
||
name="text"
|
||
className="input font-semibold text-base h-full opacity-90 w-full px-10 py-2 rounded-xl bg-black flex items-center justify-center"
|
||
onClick={handleConfirm}
|
||
>
|
||
{localLoading ? (
|
||
<>
|
||
<Loader2 className="w-3 h-3 animate-spin" />
|
||
Actioning...
|
||
</>
|
||
) : (
|
||
<>
|
||
<Clapperboard className="w-5 h-5" />
|
||
Action
|
||
</>
|
||
)}
|
||
</button>
|
||
</div>
|
||
<div className="absolute duration-1000 group-hover:animate-spin w-full h-[80px] bg-gradient-to-r from-[rgb(106,244,249)] to-[rgb(199,59,255)] blur-[30px]"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="flex items-center justify-center h-64">
|
||
<div className="text-center text-white/60">
|
||
<LayoutTemplate className="w-16 h-16 mx-auto mb-4 opacity-50" />
|
||
<p className="text-lg">No templates available</p>
|
||
<p className="text-sm">Please try again later</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
return (
|
||
<>
|
||
<style>{customAudioPlayerStyles}</style>
|
||
<Modal
|
||
open={isOpen}
|
||
onCancel={() => {
|
||
// 清空所有选中的内容数据
|
||
setSelectedTemplate(null);
|
||
setActiveRoleIndex(0);
|
||
onClose();
|
||
}}
|
||
footer={null}
|
||
width="60%"
|
||
style={{ maxWidth: "800px", marginTop: "10vh" }}
|
||
className="template-modal"
|
||
closeIcon={
|
||
<div className="w-6 h-6 bg-white/10 rounded-full flex items-center justify-center hover:bg-white/20 transition-colors">
|
||
<span className="text-white/70 text-lg leading-none flex items-center justify-center">
|
||
×
|
||
</span>
|
||
</div>
|
||
}
|
||
>
|
||
<div className="rounded-2xl">
|
||
{/* 弹窗头部 */}
|
||
<div className="flex gap-4 p-4 border-b border-white/[0.1]">
|
||
<h2 className="text-2xl font-bold text-white">
|
||
Template Story Selection
|
||
</h2>
|
||
</div>
|
||
<div className="flex gap-4">
|
||
{templateListRender()}
|
||
<div className="flex-1">{storyEditorRender()}</div>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
</>
|
||
);
|
||
};
|
||
|
||
/**
|
||
* 视频工具面板组件
|
||
* 提供脚本输入和视频克隆两种模式,支持展开/收起功能
|
||
*/
|
||
export function ChatInputBox() {
|
||
// 控制面板展开/收起状态
|
||
const [isExpanded, setIsExpanded] = useState(false);
|
||
|
||
// 模板故事弹窗状态
|
||
const [isTemplateModalOpen, setIsTemplateModalOpen] = useState(false);
|
||
|
||
// 图片故事弹窗状态
|
||
const [isPhotoStoryModalOpen, setIsPhotoStoryModalOpen] = useState(false);
|
||
|
||
// 共享状态 - 需要在不同渲染函数间共享
|
||
const [script, setScript] = useState(""); // 用户输入的脚本内容
|
||
const router = useRouter();
|
||
|
||
const [loadingIdea, setLoadingIdea] = useState(false); // 获取创意建议时的加载状态
|
||
const [isCreating, setIsCreating] = useState(false); // 视频创建过程中的加载状态
|
||
|
||
// 配置选项状态 - 整合所有配置项到一个对象
|
||
const [configOptions, setConfigOptions] = useState<{
|
||
mode: "auto" | "manual";
|
||
resolution: "720p" | "1080p" | "4k";
|
||
language: string;
|
||
videoDuration: string;
|
||
}>({
|
||
mode: "auto",
|
||
resolution: "720p",
|
||
language: "english",
|
||
videoDuration: "1min",
|
||
});
|
||
|
||
// 配置项显示控制状态
|
||
const [showConfigOptions, setShowConfigOptions] = useState(false);
|
||
|
||
const handleGetIdea = () => {
|
||
if (loadingIdea) return;
|
||
setLoadingIdea(true);
|
||
const ideaText =
|
||
"a cute capybara with an orange on its head, staring into the distance and walking forward";
|
||
setTimeout(() => {
|
||
setScript(ideaText);
|
||
setLoadingIdea(false);
|
||
}, 3000);
|
||
};
|
||
|
||
// Handle creating video
|
||
|
||
const handleCreateVideo = async () => {
|
||
setIsCreating(true);
|
||
if (!script) {
|
||
setIsCreating(false);
|
||
return;
|
||
}
|
||
const User = JSON.parse(localStorage.getItem("currentUser") || "{}");
|
||
|
||
// 创建剧集数据
|
||
let episodeData: any = {
|
||
user_id: String(User.id),
|
||
script: script,
|
||
mode: configOptions.mode,
|
||
resolution: configOptions.resolution,
|
||
language: configOptions.language,
|
||
video_duration: configOptions.videoDuration,
|
||
};
|
||
|
||
// 调用创建剧集API
|
||
const episodeResponse = await createMovieProjectV1(episodeData);
|
||
console.log("episodeResponse", episodeResponse);
|
||
if (episodeResponse.code !== 0) {
|
||
console.error(`创建剧集失败: ${episodeResponse.message}`);
|
||
alert(`创建剧集失败: ${episodeResponse.message}`);
|
||
return;
|
||
}
|
||
let episodeId = episodeResponse.data.project_id;
|
||
// let episodeId = '9c34fcc4-c8d8-44fc-879e-9bd56f608c76';
|
||
router.push(`/create/work-flow?episodeId=${episodeId}`);
|
||
setIsCreating(false);
|
||
};
|
||
|
||
return (
|
||
<div className="video-tool-component relative max-w-[1080px] w-full left-[50%] translate-x-[-50%]">
|
||
{/* 视频故事板工具面板 - 毛玻璃效果背景 */}
|
||
<div className="video-storyboard-tools rounded-[20px] bg-white/[0.08] backdrop-blur-[20px] border border-white/[0.12] shadow-[0_8px_32px_rgba(0,0,0,0.3)]">
|
||
{/* 展开/收起控制区域 */}
|
||
{isExpanded ? (
|
||
// 展开状态:显示收起按钮和提示
|
||
<div
|
||
className="absolute top-0 bottom-0 left-0 right-0 z-[1] flex flex-col items-center justify-center rounded-[20px] bg-[#191B1E] bg-opacity-[0.3] backdrop-blur-[1.5px] cursor-pointer"
|
||
onClick={() => setIsExpanded(false)}
|
||
>
|
||
<ChevronUp className="w-4 h-4 text-white/80" />
|
||
<span className="text-sm text-white/80 mt-1">Click to action</span>
|
||
</div>
|
||
) : (
|
||
// 收起状态:显示展开按钮
|
||
<div
|
||
className="absolute top-[-8px] left-[50%] z-[2] flex items-center justify-center w-[46px] h-4 rounded-[15px] bg-[#fff3] translate-x-[-50%] cursor-pointer hover:bg-[#ffffff1a]"
|
||
onClick={() => setIsExpanded(true)}
|
||
>
|
||
<ChevronDown className="w-4 h-4" />
|
||
</div>
|
||
)}
|
||
|
||
{/* 主要内容区域 - 简化层级,垂直居中 */}
|
||
<div
|
||
data-alt="+ ---------"
|
||
className={`flex items-center justify-center p-1 transition-all duration-300 relative ${
|
||
isExpanded ? "h-[16px]" : "h-auto"
|
||
}`}
|
||
>
|
||
{/* 右上角齿轮图标和配置项 */}
|
||
{!isExpanded && (
|
||
<div className="absolute top-2 right-2 z-10 flex items-center">
|
||
{/* 使用 Dropdown 替代手动控制显示/隐藏 */}
|
||
<Dropdown
|
||
open={showConfigOptions}
|
||
onOpenChange={setShowConfigOptions}
|
||
popupRender={() => (
|
||
<div className="bg-white/[0.08] border border-white/[0.12] rounded-lg shadow-[0_8px_32px_rgba(0,0,0,0.3)]">
|
||
<ConfigOptions
|
||
config={configOptions}
|
||
onConfigChange={(key, value) =>
|
||
setConfigOptions((prev) => ({ ...prev, [key]: value }))
|
||
}
|
||
/>
|
||
</div>
|
||
)}
|
||
placement={"left" as any}
|
||
trigger={["click"]}
|
||
>
|
||
{/* 配置项显示控制按钮 - 齿轮图标 */}
|
||
<Tooltip title="config" placement="top">
|
||
<button
|
||
data-alt="config-toggle-button"
|
||
className="flex items-center justify-center w-8 h-8 bg-white/[0.1] hover:bg-white/[0.2] rounded-lg border border-white/[0.2] transition-all duration-200"
|
||
>
|
||
<Settings className="w-4 h-4 text-white/80" />
|
||
</button>
|
||
</Tooltip>
|
||
</Dropdown>
|
||
</div>
|
||
)}
|
||
|
||
{/* 输入框和Action按钮 - 只在展开状态显示 */}
|
||
{!isExpanded && (
|
||
<div className="flex flex-col gap-3 w-full pl-3">
|
||
{/* 第一行:输入框 */}
|
||
<div className="video-prompt-editor relative flex flex-col gap-3 flex-1">
|
||
{/* 文本输入框 - 改为textarea */}
|
||
<textarea
|
||
value={script}
|
||
onChange={(e) => setScript(e.target.value)}
|
||
placeholder="Describe the content you want to action..."
|
||
className="w-full pl-[10px] pr-[10px] py-[14px] rounded-[10px] leading-[20px] text-sm border-none bg-transparent text-white placeholder:text-white/[0.40] focus:outline-none resize-none min-h-[48px] max-h-[120px] overflow-y-auto"
|
||
rows={1}
|
||
onInput={(e) => {
|
||
const target = e.target as HTMLTextAreaElement;
|
||
target.style.height = "auto";
|
||
target.style.height =
|
||
Math.min(target.scrollHeight, 120) + "px";
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
{/* 第二行:功能按钮和Action按钮 - 同一行 */}
|
||
<div className="flex items-center justify-between">
|
||
{/* 左侧功能按钮区域 */}
|
||
<div className="flex items-center gap-2">
|
||
{/* 获取创意按钮 */}
|
||
<Tooltip
|
||
title="Get creative ideas for your story"
|
||
placement="top"
|
||
>
|
||
<button
|
||
data-alt="get-idea-button"
|
||
className="flex items-center gap-1.5 px-3 py-2 text-white/[0.70] hover:text-white transition-colors"
|
||
onClick={() => handleGetIdea()}
|
||
>
|
||
{loadingIdea ? (
|
||
<Loader2 className="w-4 h-4 animate-spin" />
|
||
) : (
|
||
<Lightbulb className="w-4 h-4" />
|
||
)}
|
||
</button>
|
||
</Tooltip>
|
||
|
||
{/* 分隔线 */}
|
||
<div className="w-px h-4 bg-white/[0.20]"></div>
|
||
|
||
{/* 模板故事按钮 */}
|
||
<Tooltip title="Choose from movie templates" placement="top">
|
||
<button
|
||
data-alt="template-story-button"
|
||
className="flex items-center gap-1.5 px-3 py-2 text-white/[0.70] hover:text-white transition-colors"
|
||
onClick={() => setIsTemplateModalOpen(true)}
|
||
>
|
||
<LayoutTemplate className="w-4 h-4" />
|
||
</button>
|
||
</Tooltip>
|
||
|
||
{/* 分隔线 */}
|
||
<div className="w-px h-4 bg-white/[0.20]"></div>
|
||
|
||
{/* 图片故事按钮 */}
|
||
<Tooltip title="Create movie from image" placement="top">
|
||
<button
|
||
data-alt="photo-story-button"
|
||
className="flex items-center gap-1.5 px-3 py-2 text-white/[0.70] hover:text-white transition-colors"
|
||
onClick={() => setIsPhotoStoryModalOpen(true)}
|
||
>
|
||
<ImagePlay className="w-4 h-4" />
|
||
</button>
|
||
</Tooltip>
|
||
|
||
{/* 图片故事弹窗 */}
|
||
<PhotoStoryModal
|
||
isOpen={isPhotoStoryModalOpen}
|
||
onClose={() => setIsPhotoStoryModalOpen(false)}
|
||
configOptions={configOptions}
|
||
/>
|
||
</div>
|
||
|
||
{/* 右侧Action按钮 */}
|
||
<ActionButton
|
||
isCreating={isCreating}
|
||
handleCreateVideo={handleCreateVideo}
|
||
icon={<Clapperboard className="w-5 h-5" />}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 配置选项区域 - 已移至右上角 */}
|
||
{/* <ConfigOptions
|
||
config={configOptions}
|
||
onConfigChange={(key, value) =>
|
||
setConfigOptions((prev) => ({ ...prev, [key]: value }))
|
||
}
|
||
/> */}
|
||
|
||
{/* 模板故事弹窗 */}
|
||
<RenderTemplateStoryMode
|
||
isOpen={isTemplateModalOpen}
|
||
onClose={() => setIsTemplateModalOpen(false)}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 创建视频按钮
|
||
const ActionButton = ({
|
||
isCreating,
|
||
handleCreateVideo,
|
||
icon,
|
||
}: {
|
||
isCreating: boolean;
|
||
handleCreateVideo: () => void;
|
||
icon: React.ReactNode;
|
||
}) => {
|
||
return (
|
||
<div className="relative group">
|
||
<div
|
||
data-alt="action-button"
|
||
className="relative w-12 h-12 opacity-90 cursor-pointer overflow-hidden rounded-xl bg-black z-10"
|
||
onClick={isCreating ? undefined : handleCreateVideo}
|
||
>
|
||
<div className="absolute z-10 -translate-x-12 group-hover:translate-x-12 transition-all duration-700 h-full w-12 bg-gradient-to-r from-gray-500 to-white/10 opacity-30 -skew-x-12"></div>
|
||
<div className="absolute flex items-center justify-center text-white z-[1] opacity-90 rounded-xl inset-0.5 bg-black">
|
||
<button
|
||
name="text"
|
||
className="w-full h-full opacity-90 rounded-xl bg-black flex items-center justify-center"
|
||
>
|
||
{isCreating ? <Loader2 className="w-5 h-5 animate-spin" /> : icon}
|
||
</button>
|
||
</div>
|
||
<div className="absolute duration-1000 group-hover:animate-spin w-full h-12 bg-gradient-to-r from-[rgb(106,244,249)] to-[rgb(199,59,255)] blur-[20px]"></div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
/**
|
||
* 配置选项组件
|
||
* 提供视频创建的各种配置选项,位于输入框下方
|
||
*/
|
||
const ConfigOptions = ({
|
||
config,
|
||
onConfigChange,
|
||
}: {
|
||
config: {
|
||
mode: string;
|
||
resolution: string;
|
||
language: string;
|
||
videoDuration: string;
|
||
};
|
||
onConfigChange: (key: string, value: string) => void;
|
||
}) => {
|
||
const configItems = [
|
||
{
|
||
key: "mode",
|
||
icon: Package,
|
||
options: [
|
||
{ value: "auto", label: "Auto", isVip: false },
|
||
{ value: "manual", label: "Manual", isVip: true },
|
||
],
|
||
},
|
||
{
|
||
key: "resolution",
|
||
icon: Video,
|
||
options: [
|
||
{ value: "720p", label: "720P", isVip: false },
|
||
{ value: "1080p", label: "1080P", isVip: true },
|
||
{ value: "2k", label: "2K", isVip: true },
|
||
{ value: "4k", label: "4K", isVip: true },
|
||
],
|
||
},
|
||
{
|
||
key: "language",
|
||
icon: Globe,
|
||
options: [
|
||
{ value: "english", label: "English", isVip: false },
|
||
{ value: "chinese", label: "Chinese", isVip: true },
|
||
{ value: "japanese", label: "Japanese", isVip: true },
|
||
{ value: "korean", label: "Korean", isVip: true },
|
||
],
|
||
},
|
||
{
|
||
key: "videoDuration",
|
||
icon: Clock,
|
||
options: [
|
||
{ value: "1min", label: "1 Min", isVip: false },
|
||
{ value: "2min", label: "2 Min", isVip: true },
|
||
{ value: "3min", label: "3 Min", isVip: true },
|
||
],
|
||
},
|
||
];
|
||
|
||
return (
|
||
<div className={`flex items-center p-2 gap-2`}>
|
||
{configItems.map((item) => {
|
||
const IconComponent = item.icon;
|
||
const currentOption = item.options.find(
|
||
(opt) => opt.value === config[item.key as keyof typeof config]
|
||
);
|
||
return (
|
||
<Dropdown
|
||
key={item.key}
|
||
menu={{
|
||
items: item.options.map((option) => ({
|
||
key: option.value,
|
||
label: (
|
||
<div className="flex items-center justify-between px-3 py-2">
|
||
<span className="text-sm text-white">{option.label}</span>
|
||
{option.isVip && (
|
||
<Crown className="w-4 h-4 text-yellow-500" />
|
||
)}
|
||
</div>
|
||
),
|
||
})),
|
||
onClick: ({ key }) => onConfigChange(item.key, key),
|
||
}}
|
||
trigger={["click"]}
|
||
placement="topRight"
|
||
>
|
||
<button
|
||
data-alt={`config-${item.key}`}
|
||
className={`flex items-center gap-2 bg-white/[0.05] backdrop-blur-xl border border-white/[0.1] rounded-lg text-white/80 hover:bg-white/[0.08] hover:border-white/[0.2] transition-all duration-200 px-2 py-1`}
|
||
>
|
||
<IconComponent className={"w-3 h-3"} />
|
||
<span className={"text-xs"}>{currentOption?.label}</span>
|
||
{currentOption?.isVip && (
|
||
<Crown className={`w-2 h-2 text-yellow-500`} />
|
||
)}
|
||
</button>
|
||
</Dropdown>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
/**
|
||
* 角色高亮编辑器组件
|
||
* 使用 Tiptap 实现角色名称高亮和文本编辑功能
|
||
*/
|
||
const RoleHighlightEditor = ({
|
||
content,
|
||
onContentChange,
|
||
}: {
|
||
content: string;
|
||
onContentChange: (content: string) => void;
|
||
}) => {
|
||
const editor = useEditor({
|
||
extensions: [
|
||
StarterKit,
|
||
HighlightTextExtension,
|
||
Placeholder.configure({
|
||
placeholder:
|
||
"Share your creative ideas about the image and let AI create a movie story for you...",
|
||
emptyEditorClass: "is-editor-empty",
|
||
}),
|
||
],
|
||
content: "",
|
||
// 简化:移除复杂的 onUpdate 逻辑,只处理基本的文本变化
|
||
onUpdate: ({ editor }) => {
|
||
const textContent = editor.getText();
|
||
if (!textContent.trim()) {
|
||
onContentChange("");
|
||
return;
|
||
}
|
||
// 直接传递文本内容,不进行复杂的标签重建
|
||
onContentChange(textContent);
|
||
},
|
||
editorProps: {
|
||
handleKeyDown: (view, event) => {
|
||
const { from, to } = view.state.selection;
|
||
const doc = view.state.doc;
|
||
|
||
// 检查光标前后是否有角色标签
|
||
const textBefore =
|
||
from > 0 ? doc.textBetween(Math.max(0, from - 50), from) : "";
|
||
const textAfter =
|
||
to < doc.content.size
|
||
? doc.textBetween(to, Math.min(doc.content.size, to + 50))
|
||
: "";
|
||
// 匹配新的角色标签格式 <role id="C1">Dezhong Huang</role>
|
||
const beforeMatch = textBefore.match(/<role[^>]*>[^<]*$/);
|
||
const afterMatch = textAfter.match(/^[^>]*<\/role>/);
|
||
|
||
// 如果光标在角色标签内,阻止输入(只允许删除操作)
|
||
if (beforeMatch || afterMatch) {
|
||
if (event.key !== "Backspace" && event.key !== "Delete") {
|
||
event.preventDefault();
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
},
|
||
},
|
||
immediatelyRender: false,
|
||
});
|
||
|
||
useEffect(() => {
|
||
if (editor) {
|
||
if (!content || content.trim() === "") {
|
||
editor.commands.clearContent(true);
|
||
return;
|
||
}
|
||
|
||
// 将带标签的内容转换为高亮显示(支持新的角色标签格式)
|
||
const htmlContent = content.replace(
|
||
/<role[^>]*>([^<]+)<\/role>/g,
|
||
'<highlight-text type="role" text="$1" color="blue">$1</highlight-text>'
|
||
);
|
||
editor.commands.setContent(htmlContent, { emitUpdate: false });
|
||
}
|
||
}, [content, editor]);
|
||
|
||
return (
|
||
<div className="flex-1 min-w-0 relative pr-20">
|
||
<style jsx>{`
|
||
.role-name-highlight {
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
color: white !important;
|
||
padding: 2px 6px;
|
||
border-radius: 4px;
|
||
font-weight: 500;
|
||
margin: 0 2px;
|
||
user-select: none;
|
||
cursor: default;
|
||
pointer-events: none;
|
||
}
|
||
|
||
/* 移除 Tiptap 编辑器的默认焦点样式 */
|
||
.ProseMirror:focus {
|
||
outline: none !important;
|
||
border: none !important;
|
||
box-shadow: none !important;
|
||
}
|
||
|
||
.ProseMirror:focus-visible {
|
||
outline: none !important;
|
||
border: none !important;
|
||
box-shadow: none !important;
|
||
}
|
||
|
||
/* 移除编辑器容器的焦点样式 */
|
||
.ProseMirror-focused {
|
||
outline: none !important;
|
||
border: none !important;
|
||
box-shadow: none !important;
|
||
}
|
||
|
||
/* 确保编辑器内容区域没有边框和轮廓 */
|
||
.ProseMirror {
|
||
outline: none !important;
|
||
border: none !important;
|
||
box-shadow: none !important;
|
||
}
|
||
`}</style>
|
||
<EditorContent
|
||
editor={editor}
|
||
className="w-full bg-transparent border-none outline-none resize-none text-white placeholder:text-white/40 text-sm leading-relaxed min-h-[60px] focus:outline-none focus:ring-0 focus:border-0"
|
||
style={{ outline: "none", border: "none", boxShadow: "none" }}
|
||
/>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
/**
|
||
* 图片故事弹窗组件
|
||
* 提供图片上传、AI分析和故事生成功能,支持动态UI变化
|
||
*/
|
||
const PhotoStoryModal = ({
|
||
isOpen,
|
||
onClose,
|
||
configOptions = {
|
||
mode: "auto" as "auto" | "manual",
|
||
resolution: "720p" as "720p" | "1080p" | "4k",
|
||
language: "english",
|
||
videoDuration: "1min",
|
||
},
|
||
}: {
|
||
isOpen: boolean;
|
||
onClose: () => void;
|
||
configOptions?: {
|
||
mode: "auto" | "manual";
|
||
resolution: "720p" | "1080p" | "4k";
|
||
language: string;
|
||
videoDuration: string;
|
||
};
|
||
}) => {
|
||
// 使用图片故事服务hook管理状态
|
||
const {
|
||
activeImageUrl,
|
||
storyContent,
|
||
potentialGenres,
|
||
selectedCategory,
|
||
isLoading,
|
||
hasAnalyzed,
|
||
updateStoryType,
|
||
updateStoryContent,
|
||
updateCharacterName,
|
||
resetImageStory,
|
||
triggerFileSelection,
|
||
avatarComputed,
|
||
uploadAndAnalyzeImage,
|
||
setCharactersAnalysis,
|
||
originalUserDescription,
|
||
actionMovie,
|
||
} = useImageStoryServiceHook();
|
||
const { loadingText } = useLoadScriptText(isLoading);
|
||
const { uploadFile } = useUploadFile();
|
||
// 重置状态
|
||
const handleClose = () => {
|
||
resetImageStory();
|
||
onClose();
|
||
};
|
||
const router = useRouter();
|
||
// 处理图片上传
|
||
const handleImageUpload = async () => {
|
||
try {
|
||
await triggerFileSelection();
|
||
} catch (error) {
|
||
console.error("Failed to upload image:", error);
|
||
}
|
||
};
|
||
|
||
// 处理确认
|
||
const handleConfirm = async () => {
|
||
try {
|
||
// 获取当前用户信息
|
||
const User = JSON.parse(localStorage.getItem("currentUser") || "{}");
|
||
|
||
if (!User.id) {
|
||
console.error("用户未登录");
|
||
return;
|
||
}
|
||
|
||
// 调用actionMovie接口
|
||
const episodeResponse = await actionMovie(
|
||
String(User.id),
|
||
configOptions.mode as "auto" | "manual",
|
||
configOptions.resolution as "720p" | "1080p" | "4k",
|
||
configOptions.language
|
||
);
|
||
if (!episodeResponse) return;
|
||
let episodeId = episodeResponse.project_id;
|
||
// let episodeId = '9c34fcc4-c8d8-44fc-879e-9bd56f608c76';
|
||
router.push(`/create/work-flow?episodeId=${episodeId}`);
|
||
// 成功后关闭弹窗
|
||
handleClose();
|
||
} catch (error) {
|
||
console.error("创建电影项目失败:", error);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<Modal
|
||
open={isOpen}
|
||
onCancel={handleClose}
|
||
footer={null}
|
||
width="80%"
|
||
style={{ maxWidth: "1000px", marginTop: "10vh" }}
|
||
className="photo-story-modal bg-white/[0.08] backdrop-blur-[20px] [&_.ant-modal-content]:bg-white/[0.00]"
|
||
closeIcon={
|
||
<div className="w-6 h-6 bg-white/10 rounded-full flex items-center justify-center hover:bg-white/20 transition-colors">
|
||
<span className="text-white/70 text-lg leading-none flex items-center justify-center">
|
||
×
|
||
</span>
|
||
</div>
|
||
}
|
||
>
|
||
<Spin spinning={isLoading} tip={loadingText}>
|
||
<div className="rounded-2xl">
|
||
{/* 弹窗头部 */}
|
||
<div className="flex items-center gap-3 p-2 border-b border-white/[0.1]">
|
||
<ImagePlay className="w-6 h-6 text-blue-400" />
|
||
<h2 className="text-xl font-bold text-white">
|
||
Movie Generation from Image
|
||
</h2>
|
||
</div>
|
||
<div className="w-full bg-white/[0.04] border border-white/[0.1] rounded-xl p-4 mt-2">
|
||
<div className="flex items-start gap-4">
|
||
{/* 左侧:图片上传 */}
|
||
<div className="flex-shrink-0">
|
||
<div
|
||
data-alt="image-upload-area"
|
||
className={`w-24 h-24 rounded-lg flex flex-col items-center justify-center transition-all duration-300 cursor-pointer ${
|
||
activeImageUrl
|
||
? "border-2 border-white/20 bg-white/[0.05]"
|
||
: "border-2 border-dashed border-white/20 bg-white/[0.02] hover:border-white/40 hover:bg-white/[0.05] hover:scale-105"
|
||
}`}
|
||
onClick={handleImageUpload}
|
||
>
|
||
{activeImageUrl ? (
|
||
<div className="relative w-full h-full">
|
||
<img
|
||
src={activeImageUrl}
|
||
alt="Story inspiration"
|
||
className="w-full h-full object-cover rounded-lg"
|
||
/>
|
||
<Tooltip title="Clear all content !!! " placement="top">
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
resetImageStory();
|
||
}}
|
||
className="absolute top-1 right-1 w-4 h-4 bg-black/60 hover:bg-black/80 rounded-full flex items-center justify-center text-white/80 hover:text-white transition-colors"
|
||
data-alt="clear-all-button"
|
||
>
|
||
<Trash2 className="w-2.5 h-2.5" />
|
||
</button>
|
||
</Tooltip>
|
||
</div>
|
||
) : (
|
||
<div className="text-center text-white/60">
|
||
<Upload className="w-6 h-6 mx-auto mb-1 opacity-50" />
|
||
<p className="text-xs">Upload</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="flex-1 animate-in fade-in-0 slide-in-from-left-4 duration-300">
|
||
{/* 中间:头像展示(分析后显示) */}
|
||
{hasAnalyzed && avatarComputed.length > 0 && (
|
||
<div className="flex gap-2 n justify-start">
|
||
{avatarComputed.map((avatar, index) => (
|
||
<div
|
||
key={`${avatar.name}-${index}`}
|
||
className="flex flex-col items-center"
|
||
>
|
||
<div className="relative w-14 h-14 rounded-sm overflow-hidden bg-white/[0.05] border border-white/[0.1] mb-2 group cursor-pointer">
|
||
<img
|
||
src={avatar.url}
|
||
alt={avatar.name}
|
||
className="w-full h-full object-cover"
|
||
onError={(e) => {
|
||
// 如果裁剪的头像加载失败,回退到原图
|
||
const target = e.target as HTMLImageElement;
|
||
target.src = activeImageUrl;
|
||
}}
|
||
/>
|
||
{/* 删除角色按钮 - 使用Tooltip并调整z-index避免被遮挡 */}
|
||
<Tooltip
|
||
title="Remove this character from the movie"
|
||
placement="top"
|
||
>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
// 从角色分析中删除该角色
|
||
setCharactersAnalysis((charactersAnalysis) => {
|
||
const updatedCharacters =
|
||
charactersAnalysis.filter(
|
||
(char) => char.role_name !== avatar.name
|
||
);
|
||
return updatedCharacters;
|
||
});
|
||
// 从故事内容中删除该角色的所有标签和引用
|
||
const updatedStory = storyContent
|
||
.replace(
|
||
new RegExp(
|
||
`<role[^>]*>${avatar.name}<\/role>`,
|
||
"g"
|
||
),
|
||
""
|
||
)
|
||
.replace(
|
||
new RegExp(`\\b${avatar.name}\\b`, "g"),
|
||
""
|
||
)
|
||
.replace(/\s+/g, " ")
|
||
.trim();
|
||
// 更新状态
|
||
updateStoryContent(updatedStory);
|
||
}}
|
||
className="absolute top-0.5 right-0.5 w-4 h-4 bg-black/[0.4] border border-black/[0.1] text-white rounded-full flex items-center justify-center transition-colors opacity-0 group-hover:opacity-100 z-10"
|
||
>
|
||
<Trash2 className="w-2.5 h-2.5" />
|
||
</button>
|
||
</Tooltip>
|
||
{/* 上传新图片按钮 - 悬停时显示 */}
|
||
<Tooltip
|
||
title="Click to upload new image for this character"
|
||
placement="top"
|
||
>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
// 创建隐藏的文件输入框
|
||
const input = document.createElement("input");
|
||
input.type = "file";
|
||
input.accept = "image/*";
|
||
input.style.display = "none";
|
||
|
||
input.onchange = async (event) => {
|
||
const target =
|
||
event.target as HTMLInputElement;
|
||
const file = target.files?.[0];
|
||
if (file) {
|
||
try {
|
||
// 使用七牛云上传
|
||
const newImageUrl = await uploadFile(
|
||
file
|
||
);
|
||
|
||
// 更新角色分析中的图片URL
|
||
setCharactersAnalysis((prev) =>
|
||
prev.map((char) =>
|
||
char.role_name === avatar.name
|
||
? { ...char, crop_url: newImageUrl }
|
||
: char
|
||
)
|
||
);
|
||
|
||
// 清理临时元素
|
||
document.body.removeChild(input);
|
||
} catch (error) {
|
||
console.error("上传图片失败:", error);
|
||
// 清理临时元素
|
||
if (document.body.contains(input)) {
|
||
document.body.removeChild(input);
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
// 添加到DOM并触发点击
|
||
document.body.appendChild(input);
|
||
input.click();
|
||
}}
|
||
className="absolute inset-0 bg-black/20 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center"
|
||
>
|
||
<Upload className="w-4 h-4 text-white" />
|
||
</button>
|
||
</Tooltip>
|
||
</div>
|
||
<div className="relative group">
|
||
<input
|
||
type="text"
|
||
defaultValue={avatar.name}
|
||
onBlur={(e) => {
|
||
const newName = e.target.value.trim();
|
||
if (newName && newName !== avatar.name) {
|
||
updateCharacterName(avatar.name, newName);
|
||
}
|
||
}}
|
||
className="w-16 text-center text-sm text-white/80 bg-transparent border-none outline-none focus:ring-1 focus:ring-blue-400/50 rounded px-1 py-0.5 transition-all duration-200"
|
||
style={{ textAlign: "center" }}
|
||
/>
|
||
<div className="absolute inset-0 border border-transparent group-hover:border-white/20 rounded transition-all duration-200 pointer-events-none"></div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{/* 右侧:分类选择(分析后显示) */}
|
||
{hasAnalyzed && potentialGenres.length > 0 && (
|
||
<div className="flex-shrink-0 animate-in fade-in-0 slide-in-from-right-4 duration-300">
|
||
<div className="flex gap-2">
|
||
{[...potentialGenres].map((genre) => (
|
||
<button
|
||
key={genre}
|
||
onClick={() => updateStoryType(genre)}
|
||
className={`px-3 py-1.5 text-xs rounded-lg transition-all duration-200 whitespace-nowrap ${
|
||
selectedCategory === genre
|
||
? "bg-blue-500/20 border border-blue-500/40 text-blue-300"
|
||
: "bg-white/[0.05] border border-white/[0.1] text-white/60 hover:bg-white/[0.08] hover:text-white/80"
|
||
}`}
|
||
>
|
||
{genre}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{/* 原始用户描述的展示 */}
|
||
{originalUserDescription && (
|
||
<div className="mt-2 text-sm text-white/30 italic">
|
||
Your Provided Text:{originalUserDescription}
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex items-start gap-4 mt-2 relative">
|
||
{/* 文本输入框 */}
|
||
<RoleHighlightEditor
|
||
content={storyContent}
|
||
onContentChange={updateStoryContent}
|
||
/>
|
||
<div className="absolute bottom-1 right-0 flex gap-2">
|
||
{!hasAnalyzed ? (
|
||
// 分析按钮 - 使用ActionButton样式
|
||
<Tooltip
|
||
title={
|
||
activeImageUrl
|
||
? "Analyze image content"
|
||
: "Please upload an image first"
|
||
}
|
||
placement="top"
|
||
>
|
||
<ActionButton
|
||
isCreating={isLoading}
|
||
handleCreateVideo={uploadAndAnalyzeImage}
|
||
icon={<Sparkles className="w-5 h-5" />}
|
||
/>
|
||
</Tooltip>
|
||
) : (
|
||
<>
|
||
{/* Action按钮 - 使用ActionButton样式 */}
|
||
<Tooltip title="Confirm story creation" placement="top">
|
||
<ActionButton
|
||
isCreating={isLoading}
|
||
handleCreateVideo={handleConfirm}
|
||
icon={<Clapperboard className="w-5 h-5" />}
|
||
/>
|
||
</Tooltip>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Spin>
|
||
</Modal>
|
||
);
|
||
};
|