forked from 77media/video-flow
1117 lines
42 KiB
TypeScript
1117 lines
42 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect, useRef } 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,
|
||
Popconfirm,
|
||
Image,
|
||
} 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";
|
||
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,
|
||
activeRoleIndex,
|
||
activeRole,
|
||
isLoading,
|
||
getTemplateStoryList,
|
||
actionStory,
|
||
setSelectedTemplate,
|
||
setActiveRoleIndex,
|
||
AvatarAndAnalyzeFeatures,
|
||
setActiveRoleAudio,
|
||
clearData,
|
||
} = useTemplateStoryServiceHook();
|
||
|
||
// 使用上传文件hook
|
||
const { uploadFile, isUploading } = useUploadFile();
|
||
// 本地加载状态,用于 UI 反馈
|
||
const [localLoading, setLocalLoading] = useState(0);
|
||
const router = useRouter();
|
||
// 组件挂载时获取模板列表
|
||
useEffect(() => {
|
||
if (isOpen) {
|
||
getTemplateStoryList();
|
||
}
|
||
}, [isOpen, getTemplateStoryList]);
|
||
|
||
// 处理模板选择
|
||
const handleTemplateSelect = (template: StoryTemplateEntity) => {
|
||
setSelectedTemplate(template);
|
||
setActiveRoleIndex(0); // 重置角色选择
|
||
};
|
||
|
||
// 处理确认操作
|
||
const handleConfirm = async () => {
|
||
if (!selectedTemplate) return;
|
||
let timer = setInterval(() => {
|
||
setLocalLoading((prev) => {
|
||
if (prev >= 95) {
|
||
clearInterval(timer);
|
||
return 95;
|
||
}
|
||
return prev + 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(`/create/work-flow?episodeId=${projectId}`);
|
||
}
|
||
console.log("Story action created:", projectId);
|
||
onClose();
|
||
// 重置状态
|
||
setSelectedTemplate(null);
|
||
setActiveRoleIndex(0);
|
||
} catch (error) {
|
||
console.error("Failed to create story action:", error);
|
||
// 这里可以添加 toast 提示
|
||
} finally {
|
||
setLocalLoading(0);
|
||
clearInterval(timer);
|
||
}
|
||
};
|
||
// 模板列表渲染
|
||
const templateListRender = () => {
|
||
return (
|
||
<div className="w-1/3 p-4 border-r border-white/[0.1]">
|
||
<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.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">
|
||
<div
|
||
data-alt="template-preview-image"
|
||
className="relative w-full aspect-square rounded-xl overflow-hidden group cursor-pointer"
|
||
>
|
||
<img
|
||
src={selectedTemplate.image_url[0]}
|
||
alt={selectedTemplate.name}
|
||
className="w-full h-full object-contain 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"
|
||
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>
|
||
|
||
{/* 角色自定义部分 - 精简布局 */}
|
||
<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-4 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%"
|
||
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]"
|
||
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={isLoading} progress={0}>
|
||
<div className="rounded-2xl min-h-min transition-all duration-700 ease-out">
|
||
{/* 弹窗头部 */}
|
||
<div className="flex gap-4 px-4 pb-2 border-b border-white/[0.1]">
|
||
<h2 className="text-2xl font-bold text-white">
|
||
Template Story Selection
|
||
</h2>
|
||
</div>
|
||
|
||
<div className="flex gap-4 pb-4 ">
|
||
{templateListRender()}
|
||
<div className="flex-1">{storyEditorRender()}</div>
|
||
</div>
|
||
</div>
|
||
</GlobalLoad>
|
||
</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
|
||
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: 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,
|
||
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);
|
||
|
||
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(`/create/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%"
|
||
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}
|
||
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>
|
||
);
|
||
};
|