2025-08-31 04:43:14 +08:00

1578 lines
62 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, useRef } from "react";
import {
ChevronDown,
ChevronUp,
Video,
Loader2,
Lightbulb,
Package,
Crown,
Clapperboard,
Globe,
Clock,
Trash2,
LayoutTemplate,
ImagePlay,
Sparkles,
Settings,
MoreHorizontal,
} from "lucide-react";
import {
Dropdown,
Modal,
Tooltip,
Upload,
Popconfirm,
Image,
Popover,
} 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 {
MovieProjectService,
MovieProjectMode,
} from "@/app/service/Interaction/MovieProjectService";
import { useLoadScriptText, useUploadFile } from "@/app/service/domain/service";
import { ActionButton } from "../common/ActionButton";
import { HighlightEditor } from "../common/HighlightEditor";
import GlobalLoad from "../common/GlobalLoad";
/**模板故事模式弹窗组件 */
const RenderTemplateStoryMode = ({
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 {
templateStoryList,
selectedTemplate,
isLoading,
getTemplateStoryList,
actionStory,
setSelectedTemplate,
AvatarAndAnalyzeFeatures,
updateRoleImage,
updateItemImage,
handleRoleFieldBlur,
handleItemFieldBlur,
clearData,
} = useTemplateStoryServiceHook();
// 使用上传文件hook
const { uploadFile, isUploading } = useUploadFile();
// 本地加载状态,用于 UI 反馈
const [localLoading, setLocalLoading] = useState(0);
// 控制输入框显示状态
const [inputVisible, setInputVisible] = useState<{ [key: string]: boolean }>(
{}
);
const router = useRouter();
// 组件挂载时获取模板列表
useEffect(() => {
if (isOpen) {
getTemplateStoryList();
}
}, [isOpen, getTemplateStoryList]);
// 监听点击外部区域关闭输入框
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Element;
// 检查是否点击了输入框相关的元素
if (
!target.closest(".ant-tooltip") &&
!target.closest('[data-alt*="field-ai-button"]')
) {
// 关闭所有打开的输入框
setInputVisible({});
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
// 处理模板选择
const handleTemplateSelect = (template: StoryTemplateEntity) => {
setSelectedTemplate(template);
};
// 处理确认操作
const handleConfirm = async () => {
if (!selectedTemplate) return;
let timer = setInterval(() => {
setLocalLoading((prev) => {
if (prev >= 95) {
clearInterval(timer);
return 95;
}
return prev + 0.1;
});
}, 100);
try {
setLocalLoading(1);
// 获取当前用户信息
const User = JSON.parse(localStorage.getItem("currentUser") || "{}");
if (!User.id) {
console.error("用户未登录");
return;
}
const projectId = await actionStory(
String(User.id),
configOptions.mode,
configOptions.resolution,
configOptions.language
);
if (projectId) {
// 跳转到电影详情页
router.push(`/movies/work-flow?episodeId=${projectId}`);
onClose();
// 重置状态
setSelectedTemplate(null);
}
console.log("Story action created:", projectId);
} catch (error) {
console.error("Failed to create story action:", error);
// 这里可以添加 toast 提示
onClose();
// 重置状态
setSelectedTemplate(null);
} finally {
setLocalLoading(0);
clearInterval(timer);
}
};
// 模板列表渲染
const templateListRender = () => {
return (
<div className="w-1/3 p-4 border-r border-white/[0.1] overflow-y-auto">
<div className="space-y-4 overflow-y-auto 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.image_url[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 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/4">
<Image
src={selectedTemplate.image_url[0]}
alt={selectedTemplate.name}
className="w-4 h-5 !object-contain transition-all duration-500 group-hover:scale-105 group-hover:rotate-1"
/>
</div>
{/* 右侧信息 - 增加文本渲染空间 */}
<div className="flex-1 flex flex-col">
<h2
data-alt="template-title"
className="text-2xl font-bold text-white mb-2"
>
{selectedTemplate.name}
</h2>
<div
className="flex-1 overflow-y-auto max-h-96 "
style={{
scrollbarWidth: "thin",
scrollbarColor: "rgba(156,163,175,0.2) rgba(0,0,0,0)",
}}
>
<p
data-alt="template-description"
className="text-gray-300 text-sm leading-relaxed"
>
{selectedTemplate.generateText}
</p>
</div>
</div>
</div>
{/* 角色配置区域 */}
{selectedTemplate?.storyRole &&
selectedTemplate.storyRole.length > 0 && (
<div className="pt-2 border-t border-white/10">
<h3
data-alt="roles-section-title"
className="text-lg font-semibold text-white mb-4"
>
Character Configuration
</h3>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{selectedTemplate.storyRole.map((role, index) => (
<div
key={index}
data-alt={`role-field-${index}`}
className="flex flex-col items-center space-y-3"
>
{/* 图片容器 */}
<div className="relative group">
<Tooltip
title={
<div className="relative">
<input
type="text"
value={role.role_description || ""}
onChange={(e) => {
// 更新角色的描述字段
const updatedTemplate = {
...selectedTemplate!,
storyRole: selectedTemplate!.storyRole.map(
(r) =>
r.role_name === role.role_name
? {
...r,
role_description: e.target.value,
}
: r
),
};
setSelectedTemplate(updatedTemplate);
}}
placeholder={role.user_tips}
className="w-[30rem] px-3 py-2 pr-16 bg-white/0 border border-white/10 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/30 transition-all duration-200 text-sm"
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2">
{/* AI生成按钮 */}
<ActionButton
isCreating={false}
handleCreateVideo={() => {
if (
role.role_description &&
role.role_description.trim()
) {
handleRoleFieldBlur(
role.role_name,
role.role_description.trim()
);
}
setInputVisible((prev) => ({
...prev,
[role.role_name]: false,
}));
}}
icon={<Sparkles className="w-4 h-4" />}
width="w-8"
height="h-8"
/>
</div>
</div>
}
placement="top"
classNames={{
root: "max-w-none",
}}
open={inputVisible[role.role_name]}
onOpenChange={(visible) =>
setInputVisible((prev) => ({
...prev,
[role.role_name]: visible,
}))
}
trigger="contextMenu"
styles={{ root: { zIndex: 1000 } }}
>
{/* 图片 */}
<div
data-alt={`role-thumbnail-${index}`}
className="w-24 h-24 rounded-xl overflow-hidden border border-white/10 bg-white/0 flex items-center justify-center cursor-pointer hover:scale-105 transition-all duration-200"
>
<Image
src={role.photo_url || "/assets/empty_video.png"}
alt={role.role_name}
className="w-full h-full object-cover"
preview={{
mask: null,
maskClassName: "hidden",
}}
fallback="/assets/empty_video.png"
/>
</div>
</Tooltip>
{/* 角色名称 - 图片下方 */}
<div className="text-center mt-2">
<span className="text-white text-sm font-medium">
{role.role_name}
</span>
</div>
{/* 按钮组 - 右上角 */}
<div className="absolute -top-8 left-[1.2rem] flex gap-3 opacity-0 group-hover:opacity-100 transition-all duration-200">
{/* AI生成按钮 */}
<Tooltip title="AI generate image" placement="top">
<button
data-alt={`role-ai-button-${index}`}
onClick={() =>
setInputVisible((prev) => ({
...prev,
[role.role_name]: !prev[role.role_name],
}))
}
className="w-6 h-6 bg-purple-500 hover:bg-purple-600 text-white rounded-full flex items-center justify-center transition-all duration-200 hover:scale-110 shadow-lg"
>
<Sparkles className="w-3.5 h-3.5" />
</button>
</Tooltip>
{/* 上传按钮 */}
<Upload
name="roleImage"
showUploadList={false}
beforeUpload={(file) => {
const isImage = file.type.startsWith("image/");
if (!isImage) {
console.error("只能上传图片文件");
return false;
}
const isLt5M = file.size / 1024 / 1024 < 5;
if (!isLt5M) {
console.error("图片大小不能超过5MB");
return false;
}
return true;
}}
customRequest={async ({
file,
onSuccess,
onError,
}) => {
try {
const fileObj = file as File;
const uploadedUrl = await uploadFile(
fileObj,
(progress) => {
console.log(`上传进度: ${progress}%`);
}
);
await AvatarAndAnalyzeFeatures(
uploadedUrl,
role.role_name
);
onSuccess?.(uploadedUrl);
} catch (error) {
console.error("角色图片上传失败:", error);
onError?.(error as Error);
}
}}
>
<Tooltip title="upload your image" placement="top">
<button
data-alt={`role-upload-button-${index}`}
className="w-6 h-6 bg-blue-500 hover:bg-blue-600 text-white rounded-full flex items-center justify-center transition-all duration-200 hover:scale-110 shadow-lg"
>
<UploadOutlined className="w-3.5 h-3.5" />
</button>
</Tooltip>
</Upload>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* 道具配置区域 */}
{selectedTemplate?.storyItem &&
selectedTemplate.storyItem.length > 0 && (
<div className="pt-2 border-t border-white/10">
<h3
data-alt="items-section-title"
className="text-lg font-semibold text-white mb-4"
>
props Configuration
</h3>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{selectedTemplate.storyItem.map((item, index) => (
<div
key={index}
data-alt={`item-field-${index}`}
className="flex flex-col items-center space-y-3"
>
{/* 图片容器 */}
<div className="relative group">
<Tooltip
title={
<div className="relative">
<input
type="text"
value={item.item_description || ""}
onChange={(e) => {
// 更新道具的描述字段
const updatedTemplate = {
...selectedTemplate!,
storyItem: selectedTemplate!.storyItem.map(
(i) =>
i.item_name === item.item_name
? {
...i,
item_description: e.target.value,
}
: i
),
};
setSelectedTemplate(updatedTemplate);
}}
placeholder="Enter description for AI image generation..."
className="w-[30rem] px-3 py-2 pr-16 bg-white/0 border border-white/10 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/30 transition-all duration-200 text-sm"
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2">
{/* AI生成按钮 */}
<ActionButton
isCreating={false}
handleCreateVideo={() => {
if (
item.item_description &&
item.item_description.trim()
) {
handleItemFieldBlur(
item.item_name,
item.item_description.trim()
);
}
setInputVisible((prev) => ({
...prev,
[item.item_name]: false,
}));
}}
icon={<Sparkles className="w-4 h-4" />}
width="w-8"
height="h-8"
/>
</div>
</div>
}
placement="top"
classNames={{
root: "max-w-none",
}}
open={inputVisible[item.item_name]}
onOpenChange={(visible) =>
setInputVisible((prev) => ({
...prev,
[item.item_name]: visible,
}))
}
trigger="contextMenu"
styles={{ root: { zIndex: 1000 } }}
>
{/* 图片 */}
<div
data-alt={`item-thumbnail-${index}`}
className="w-24 h-24 rounded-xl overflow-hidden border border-white/10 bg-white/0 flex items-center justify-center cursor-pointer hover:scale-105 transition-all duration-200"
>
<Image
src={item.photo_url || "/assets/empty_video.png"}
alt={item.item_name}
className="w-full h-full object-cover"
preview={{
mask: null,
maskClassName: "hidden",
}}
fallback="/assets/empty_video.png"
/>
</div>
</Tooltip>
{/* 道具名称 - 图片下方 */}
<div className="text-center mt-2">
<span className="text-white text-sm font-medium">
{item.item_name}
</span>
</div>
{/* 按钮组 - 右上角 */}
<div className="absolute -top-8 left-[1.2rem] flex gap-3 opacity-0 group-hover:opacity-100 transition-all duration-200">
{/* AI生成按钮 */}
<Tooltip title="AI generate image" placement="top">
<button
data-alt={`item-ai-button-${index}`}
onClick={() =>
setInputVisible((prev) => ({
...prev,
[item.item_name]: !prev[item.item_name],
}))
}
className="w-6 h-6 bg-purple-500 hover:bg-purple-600 text-white rounded-full flex items-center justify-center transition-all duration-200 hover:scale-110 shadow-lg"
>
<Sparkles className="w-3.5 h-3.5" />
</button>
</Tooltip>
{/* 上传按钮 */}
<Upload
name="itemImage"
showUploadList={false}
beforeUpload={(file) => {
const isImage = file.type.startsWith("image/");
if (!isImage) {
console.error("只能上传图片文件");
return false;
}
const isLt5M = file.size / 1024 / 1024 < 5;
if (!isLt5M) {
console.error("图片大小不能超过5MB");
return false;
}
return true;
}}
customRequest={async ({
file,
onSuccess,
onError,
}) => {
try {
const fileObj = file as File;
const uploadedUrl = await uploadFile(
fileObj,
(progress) => {
console.log(`上传进度: ${progress}%`);
}
);
updateItemImage(item.item_name, uploadedUrl);
onSuccess?.(uploadedUrl);
} catch (error) {
console.error("道具图片上传失败:", error);
onError?.(error as Error);
}
}}
>
<Tooltip title="upload your image" placement="top">
<button
data-alt={`item-upload-button-${index}`}
className="w-6 h-6 bg-blue-500 hover:bg-blue-600 text-white rounded-full flex items-center justify-center transition-all duration-200 hover:scale-110 shadow-lg"
>
<UploadOutlined className="w-3.5 h-3.5" />
</button>
</Tooltip>
</Upload>
</div>
</div>
</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">
{/* 音频操作区域 - 使用新的 AudioRecorder 组件 */}
{/* <div className="">
<AudioRecorder
audioUrl={activeRole?.voice_url || ""}
onAudioRecorded={(audioBlob, audioUrl) => {
setActiveRoleAudio(audioUrl);
}}
onAudioDeleted={() => {
setActiveRoleAudio("");
}}
/>
</div>
</div>
{/* 右侧:角色图片缩略图列表 - 精简 */}
{/* <div className="w-24 flex flex-col gap-y-[.6rem] ">
{selectedTemplate.storyRole.map((role, index: number) => (
<div key={index} className="relative group">
<Tooltip 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)}
>
<Image
src={role.photo_url}
alt={role.role_name}
className="w-full h-full object-cover"
preview={{
mask: null,
maskClassName: "hidden",
}}
fallback="/assets/empty_video.png"
/>
</button>
</Tooltip>
{/* 上传按钮 - 右上角 */}
{/* <Tooltip title="更换角色头像" placement="top">
<Upload
name="avatar"
showUploadList={false}
beforeUpload={(file) => {
// 验证文件类型
const isImage = file.type.startsWith("image/");
if (!isImage) {
console.error("只能上传图片文件");
return false;
}
// 验证文件大小 (10MB)
const isLt10M = file.size / 1024 / 1024 < 10;
if (!isLt10M) {
console.error("图片大小不能超过10MB");
return false;
}
return true;
}}
customRequest={async ({ file, onSuccess, onError }) => {
try {
const fileObj = file as File;
console.log(
"开始上传图片文件:",
fileObj.name,
fileObj.type,
fileObj.size
);
// 使用 hook 上传文件到七牛云
const uploadedUrl = await uploadFile(
fileObj,
(progress) => {
console.log(`上传进度: ${progress}%`);
}
);
console.log("图片上传成功URL:", uploadedUrl);
// 上传成功后,更新角色图片
await AvatarAndAnalyzeFeatures(uploadedUrl);
onSuccess?.(uploadedUrl);
} catch (error) {
console.error("图片上传失败:", error);
onError?.(error as Error);
}
}}
>
<button
data-alt={`upload-button-${index}`}
className="absolute -top-1 -right-1 w-6 h-6 bg-blue-500 hover:bg-blue-600 text-white rounded-full flex items-center justify-center transition-all duration-200 opacity-0 group-hover:opacity-100 hover:scale-110 shadow-lg"
title="更换角色头像"
>
<UploadOutlined className="w-3 h-3" />
</button>
</Upload>
</Tooltip>
</div>
))}
</div>
</div>
</div> */}
<div className=" absolute -bottom-8 right-0">
<ActionButton
isCreating={localLoading > 0}
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={() => {
// 清空所有选中的内容数据
clearData();
onClose();
}}
footer={null}
width="60%"
closable={false}
maskClosable={false}
style={{ maxWidth: "800px", marginTop: "0vh" }}
className="photo-story-modal !pb-0 rounded-lg bg-white/[0.08] backdrop-blur-[20px] [&_.ant-modal-content]:bg-white/[0.00]"
>
<GlobalLoad show={isLoading} progress={localLoading}>
<div className="rounded-2xl h-[70vh] overflow-y-hidden flex flex-col">
{/* 弹窗头部 */}
<div className="flex items-center justify-between px-4 pb-6 border-b border-white/[0.1]">
<h2 className="text-2xl font-bold text-white">
Template Story Selection
</h2>
<button
onClick={onClose}
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">×</span>
</button>
</div>
<div className="flex gap-4 pb-8 flex-1 overflow-y-hidden">
{templateListRender()}
<div className="flex-1">{storyEditorRender()}</div>
</div>
</div>
</GlobalLoad>
</Modal>
</>
);
};
/**
* 视频工具面板组件
* 提供脚本输入和视频克隆两种模式,支持展开/收起功能
*/
export function ChatInputBox({ noData }: { noData: boolean }) {
// 控制面板展开/收起状态
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",
});
// 从 localStorage 初始化配置
useEffect(() => {
const savedConfig = localStorage.getItem('videoFlowConfig');
if (savedConfig) {
try {
const parsed = JSON.parse(savedConfig);
setConfigOptions({
mode: parsed.mode || "auto",
resolution: parsed.resolution || "720p",
language: parsed.language || "english",
videoDuration: parsed.videoDuration || "1min",
});
} catch (error) {
console.warn('解析保存的配置失败,使用默认配置:', error);
}
}
}, []);
// 配置项显示控制状态
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
try {
const result = await MovieProjectService.createProject(
MovieProjectMode.NORMAL,
episodeData
);
const episodeId = result.project_id;
router.push(`/movies/work-flow?episodeId=${episodeId}`);
} catch (error) {
console.error("创建剧集失败:", error);
} finally {
setIsCreating(false);
}
};
return (
<div
className="video-tool-component relative max-w-[1080px] w-full left-[50%] translate-x-[-50%]"
style={
noData
? {
top: "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)]">
{/* 展开/收起控制区域 */}
{!noData && (
<>
{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) => {
const newConfig = { ...prev, [key]: value };
// 保存到 localStorage
if (typeof window !== 'undefined') {
localStorage.setItem('videoFlowConfig', JSON.stringify(newConfig));
}
return newConfig;
});
}}
/>
</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 pr-10">
{/* 文本输入框 - 改为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"
style={
noData
? {
minHeight: "128px",
}
: {}
}
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" />}
className="mr-1 mb-1"
/>
</div>
</div>
)}
</div>
</div>
{/* 配置选项区域 - 已移至右上角 */}
{/* <ConfigOptions
config={configOptions}
onConfigChange={(key, value) =>
setConfigOptions((prev) => ({ ...prev, [key]: value }))
}
/> */}
{/* 模板故事弹窗 */}
<RenderTemplateStoryMode
configOptions={configOptions}
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: false },
{ value: "japanese", label: "Japanese", isVip: false },
{ value: "spanish", label: "Spanish", isVip: false },
{ value: "portuguese", label: "Portuguese", isVip: false },
{ value: "hindi", label: "Hindi", isVip: false },
{ value: "korean", label: "Korean", isVip: false },
{ value: "arabic", label: "Arabic", isVip: false },
{ value: "russian", label: "Russian", isVip: false },
],
},
// {
// 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
data-alt="config-options"
className="flex items-center p-2 gap-2 bg-white/10 backdrop-blur-md rounded-lg"
>
{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`} />
)}
<MoreHorizontal className="w-2.5 h-2.5 text-white/60" />
</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,
taskProgress,
updateStoryType,
updateStoryContent,
updateCharacterName,
resetImageStory,
triggerFileSelection,
avatarComputed,
uploadAndAnalyzeImage,
setCharactersAnalysis,
originalUserDescription,
actionMovie,
uploadCharacterAvatarAndAnalyzeFeatures,
} = useImageStoryServiceHook();
const { loadingText } = useLoadScriptText(isLoading);
const [localLoading, setLocalLoading] = useState(0);
// 重置状态
const handleClose = () => {
// resetImageStory();
onClose();
};
const router = useRouter();
const taskProgressRef = useRef(taskProgress);
const [cursorPosition, setCursorPosition] = useState(0);
const handleCursorPositionChange = (position: number) => {
setCursorPosition(position);
};
useEffect(() => {
taskProgressRef.current = taskProgress;
}, [taskProgress]);
// 处理图片上传
const handleImageUpload = async (e: any) => {
const target = e.target as HTMLImageElement;
if (
!(target.tagName == "IMG" || e.target.dataset.alt == "image-upload-area")
) {
return;
}
e.preventDefault();
e.stopPropagation();
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(`/movies/work-flow?episodeId=${episodeId}`);
// 成功后关闭弹窗
handleClose();
} catch (error) {
console.error("创建电影项目失败:", error);
}
};
const handleAnalyzeImage = async () => {
let timeout = 100;
let timer: NodeJS.Timeout;
timer = setInterval(() => {
const currentProgress = taskProgressRef.current;
setLocalLoading((prev) => {
if (prev >= currentProgress && currentProgress != 0) {
return currentProgress;
}
return prev + 0.1;
});
}, timeout);
try {
await uploadAndAnalyzeImage();
} finally {
clearInterval(timer);
setLocalLoading(0);
}
};
return (
<Modal
open={isOpen}
onCancel={handleClose}
footer={null}
width="80%"
maskClosable={false}
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>
}
>
<GlobalLoad show={localLoading > 0} progress={localLoading}>
<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-32 h-32 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-contain rounded-lg bg-white/[0.05]"
/>
<Popconfirm
title="Clear all content"
description="Are you sure you want to clear all content? This action cannot be undone."
onConfirm={() => {
resetImageStory();
}}
okText="Yes"
cancelText="No"
showCancel={false}
okType="default"
placement="top"
classNames={{
root: "text-white event-pointer",
body: "text-white border rounded-lg bg-white/[0.04] [&_.ant-popconfirm-description]:!text-white [&_.ant-popconfirm-title]:!text-white [&_.ant-btn]:!text-white",
}}
>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
className="absolute top-1 right-1 w-4 h-4 bg-white/20 hover:bg-white/30 backdrop-blur-sm rounded-md flex items-center justify-center text-white/90 hover:text-white border border-white/20 hover:border-white/30 transition-all duration-200 z-10 shadow-sm hover:shadow-md"
data-alt="clear-all-button"
>
<Trash2 className="w-3 h-3" />
</button>
</Popconfirm>
</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-20 h-20 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-contain bg-white/[0.05]"
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}
onCursorPositionChange={handleCursorPositionChange}
cursorPosition={cursorPosition}
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={handleAnalyzeImage}
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>
</GlobalLoad>
</Modal>
);
};