1062 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useState, useEffect } from "react";
import {
ChevronDown,
ChevronUp,
Video,
Loader2,
Lightbulb,
Package,
Crown,
Clapperboard,
Globe,
Clock,
Trash2,
LayoutTemplate,
ImagePlay,
Sparkles,
Settings,
} from "lucide-react";
import { Dropdown, Modal, Tooltip, Upload, Spin } from "antd";
import { UploadOutlined } 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 { useRouter } from "next/navigation";
import { createMovieProjectV1 } from "@/api/video_flow";
import { useLoadScriptText, useUploadFile } from "@/app/service/domain/service";
import { ActionButton } from "../common/ActionButton";
import { HighlightEditor } from "../common/HighlightEditor";
/**模板故事模式弹窗组件 */
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-4 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 className="relative h-full">
{/* 模板信息头部 - 增加顶部空间 */}
<div className="flex gap-3 py-4 border-b border-white/[0.1] h-[300px]">
{/* 左侧图片 */}
<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-4"
>
{selectedTemplate.name}
</h2>
<div className="flex-1 overflow-y-auto max-h-96 pr-3">
<p
data-alt="template-description"
className="text-gray-300 text-sm leading-relaxed"
>
{selectedTemplate.generateText}
</p>
</div>
</div>
</div>
{/* 角色自定义部分 - 精简布局 */}
<div className="p-4">
<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 mt-4">
<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 className=" absolute bottom-0 right-0">
<ActionButton
isCreating={localLoading}
handleCreateVideo={handleConfirm}
icon={<Clapperboard className="w-5 h-5" />}
/>
</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 (
<>
<Modal
open={isOpen}
onCancel={() => {
// 清空所有选中的内容数据
setSelectedTemplate(null);
setActiveRoleIndex(0);
onClose();
}}
footer={null}
width="60%"
style={{ maxWidth: "800px", marginTop: "0vh" }}
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 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>
);
};
/**
* 图片故事弹窗组件
* 提供图片上传、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,
uploadCharacterAvatarAndAnalyzeFeatures,
} = 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();
// 使用新的上传人物头像并分析特征方法
uploadCharacterAvatarAndAnalyzeFeatures(
avatar.name
).catch((error) => {
console.error("上传人物头像失败:", error);
});
}}
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">
{/* 文本输入框 */}
<HighlightEditor
content={storyContent}
onContentChange={updateStoryContent}
type={"role"}
placeholder="Share your creative ideas about the image and let AI create a movie story for you..."
/>
<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>
);
};