forked from 77media/video-flow
1528 lines
58 KiB
TypeScript
1528 lines
58 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useRef, useEffect } from "react";
|
||
import {
|
||
ChevronDown,
|
||
ChevronUp,
|
||
Video,
|
||
Loader2,
|
||
Lightbulb,
|
||
Package,
|
||
Crown,
|
||
ArrowUp,
|
||
Globe,
|
||
AudioLines,
|
||
Info,
|
||
Clock,
|
||
Trash2
|
||
} from "lucide-react";
|
||
import { Dropdown, Modal, Tooltip, Upload } from "antd";
|
||
import { PlusOutlined, UploadOutlined } from "@ant-design/icons";
|
||
import type { MenuProps } from "antd";
|
||
import { ModeEnum, ResolutionEnum, VideoDurationEnum } from "@/app/model/enums";
|
||
import {
|
||
StoryTemplateEntity,
|
||
ImageStoryEntity,
|
||
} from "@/app/service/domain/Entities";
|
||
import AudioPlayer from "react-h5-audio-player";
|
||
import "react-h5-audio-player/lib/styles.css";
|
||
|
||
// 自定义音频播放器样式
|
||
const customAudioPlayerStyles = `
|
||
.custom-audio-player {
|
||
background: rgba(255, 255, 255, 0.05) !important;
|
||
border-radius: 8px !important;
|
||
border: 1px solid rgba(255, 255, 255, 0.1) !important;
|
||
}
|
||
.custom-audio-player .rhap_main-controls-button {
|
||
color: white !important;
|
||
}
|
||
.custom-audio-player .rhap_progress-filled {
|
||
background-color: #3b82f6 !important;
|
||
}
|
||
.custom-audio-player .rhap_progress-indicator {
|
||
background-color: #3b82f6 !important;
|
||
}
|
||
.custom-audio-player .rhap_time {
|
||
color: rgba(255, 255, 255, 0.7) !important;
|
||
}
|
||
.custom-audio-player .rhap_volume-controls {
|
||
color: white !important;
|
||
}
|
||
`;
|
||
|
||
/**模板故事模式 */
|
||
const RenderTemplateStoryMode = () => {
|
||
const [templates, setTemplates] = useState<StoryTemplateEntity[]>([]);
|
||
const [selectedTemplate, setSelectedTemplate] =
|
||
useState<StoryTemplateEntity | null>(null);
|
||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||
const [loading, setLoading] = useState(false);
|
||
// 角色资源状态管理
|
||
const [roleImages, setRoleImages] = useState<{ [key: number]: string }>({});
|
||
const [roleAudios, setRoleAudios] = useState<{ [key: number]: string }>({});
|
||
const [selectedRoleIndex, setSelectedRoleIndex] = useState<number>(0);
|
||
// 模拟API请求获取模板数据
|
||
const fetchTemplates = async () => {
|
||
setLoading(true);
|
||
try {
|
||
// 模拟API调用,实际项目中替换为真实API
|
||
const mockTemplates: StoryTemplateEntity[] = [
|
||
{
|
||
id: "1",
|
||
name: "Three Little Dwarves",
|
||
imageUrl: "https://picsum.photos/200/200?random=1",
|
||
generateText:
|
||
"A story about courage and friendship, where the protagonist faces numerous challenges...",
|
||
storyRole: ["Brave Warrior", "Wise Mage", "Loyal Companion"],
|
||
userResources: [],
|
||
},
|
||
{
|
||
id: "2",
|
||
name: "Seven Old Women",
|
||
imageUrl: "https://picsum.photos/200/200?random=2",
|
||
generateText:
|
||
"In a futuristic world where technology and humanity intertwine, exploring the unknown mysteries of the universe...",
|
||
storyRole: ["Space Explorer", "AI Assistant", "Alien Creature"],
|
||
userResources: [],
|
||
},
|
||
{
|
||
id: "3",
|
||
name: "Nine Wooden Men",
|
||
imageUrl: "https://picsum.photos/200/200?random=3",
|
||
generateText:
|
||
"A magical fairy tale world where good battles evil, leading to a beautiful ending...",
|
||
storyRole: ["Princess", "Prince", "Magician"],
|
||
userResources: [],
|
||
},
|
||
];
|
||
setTemplates(mockTemplates);
|
||
} catch (error) {
|
||
console.error("Failed to fetch templates:", error);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
fetchTemplates();
|
||
}, []);
|
||
|
||
// 处理模板选择
|
||
const handleTemplateSelect = (template: StoryTemplateEntity) => {
|
||
setSelectedTemplate(template);
|
||
setIsModalOpen(true);
|
||
};
|
||
|
||
// 处理确认操作
|
||
const handleConfirm = () => {
|
||
// 这里可以添加实际的API调用逻辑
|
||
console.log("Template confirmed:", selectedTemplate);
|
||
setIsModalOpen(false);
|
||
setSelectedTemplate(null);
|
||
};
|
||
|
||
// 处理角色图片上传
|
||
const handleRoleImageUpload = (roleIndex: number, file: any) => {
|
||
if (file && selectedTemplate) {
|
||
// 这里可以添加实际的上传逻辑
|
||
console.log("Character image uploaded:", file.name);
|
||
// 模拟上传成功,设置图片URL
|
||
const imageUrl = URL.createObjectURL(file);
|
||
setRoleImages((prev) => ({ ...prev, [roleIndex]: imageUrl }));
|
||
|
||
// 保存到模板资源中
|
||
const roleName = selectedTemplate.storyRole[roleIndex];
|
||
|
||
// 查找是否已存在该角色的资源记录
|
||
const existingResourceIndex = selectedTemplate.userResources.findIndex(
|
||
(resource) => resource.role_name === roleName
|
||
);
|
||
|
||
let updatedTemplate;
|
||
if (existingResourceIndex >= 0) {
|
||
// 如果已存在,更新现有记录
|
||
const updatedResources = [...selectedTemplate.userResources];
|
||
updatedResources[existingResourceIndex] = {
|
||
...updatedResources[existingResourceIndex],
|
||
photo_url: imageUrl,
|
||
};
|
||
updatedTemplate = {
|
||
...selectedTemplate,
|
||
userResources: updatedResources,
|
||
};
|
||
} else {
|
||
// 如果不存在,创建新记录
|
||
updatedTemplate = {
|
||
...selectedTemplate,
|
||
userResources: [
|
||
...selectedTemplate.userResources,
|
||
{
|
||
role_name: roleName,
|
||
photo_url: imageUrl,
|
||
voice_url: "",
|
||
},
|
||
],
|
||
};
|
||
}
|
||
|
||
setSelectedTemplate(updatedTemplate);
|
||
}
|
||
};
|
||
|
||
// 删除角色图片
|
||
const handleDeleteRoleImage = (roleIndex: number) => {
|
||
setRoleImages((prev) => {
|
||
const newState = { ...prev };
|
||
delete newState[roleIndex];
|
||
return newState;
|
||
});
|
||
|
||
// 同时从模板资源中删除图片
|
||
if (selectedTemplate) {
|
||
const roleName = selectedTemplate.storyRole[roleIndex];
|
||
const existingResourceIndex = selectedTemplate.userResources.findIndex(
|
||
(resource) => resource.role_name === roleName
|
||
);
|
||
|
||
if (existingResourceIndex >= 0) {
|
||
const updatedResources = [...selectedTemplate.userResources];
|
||
updatedResources[existingResourceIndex] = {
|
||
...updatedResources[existingResourceIndex],
|
||
photo_url: "",
|
||
};
|
||
|
||
const updatedTemplate = {
|
||
...selectedTemplate,
|
||
userResources: updatedResources,
|
||
};
|
||
setSelectedTemplate(updatedTemplate);
|
||
}
|
||
}
|
||
};
|
||
|
||
// 处理音频录制
|
||
const handleAudioRecord = async (roleIndex: number) => {
|
||
try {
|
||
// 请求麦克风权限
|
||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||
|
||
// 创建 MediaRecorder
|
||
const mediaRecorder = new MediaRecorder(stream);
|
||
const audioChunks: Blob[] = [];
|
||
|
||
mediaRecorder.ondataavailable = (event) => {
|
||
audioChunks.push(event.data);
|
||
};
|
||
|
||
mediaRecorder.onstop = () => {
|
||
// 停止所有音轨
|
||
stream.getTracks().forEach((track) => track.stop());
|
||
|
||
// 创建音频 Blob
|
||
const audioBlob = new Blob(audioChunks, { type: "audio/wav" });
|
||
const audioUrl = URL.createObjectURL(audioBlob);
|
||
|
||
// 保存到状态
|
||
setRoleAudios((prev) => ({ ...prev, [roleIndex]: audioUrl }));
|
||
|
||
// 保存到模板资源中
|
||
if (selectedTemplate) {
|
||
const roleName = selectedTemplate.storyRole[roleIndex];
|
||
|
||
// 查找是否已存在该角色的资源记录
|
||
const existingResourceIndex =
|
||
selectedTemplate.userResources.findIndex(
|
||
(resource) => resource.role_name === roleName
|
||
);
|
||
|
||
let updatedTemplate;
|
||
if (existingResourceIndex >= 0) {
|
||
// 如果已存在,更新现有记录
|
||
const updatedResources = [...selectedTemplate.userResources];
|
||
updatedResources[existingResourceIndex] = {
|
||
...updatedResources[existingResourceIndex],
|
||
voice_url: audioUrl,
|
||
};
|
||
updatedTemplate = {
|
||
...selectedTemplate,
|
||
userResources: updatedResources,
|
||
};
|
||
} else {
|
||
// 如果不存在,创建新记录
|
||
updatedTemplate = {
|
||
...selectedTemplate,
|
||
userResources: [
|
||
...selectedTemplate.userResources,
|
||
{
|
||
role_name: roleName,
|
||
photo_url: "",
|
||
voice_url: audioUrl,
|
||
},
|
||
],
|
||
};
|
||
}
|
||
|
||
setSelectedTemplate(updatedTemplate);
|
||
}
|
||
};
|
||
|
||
// 开始录制
|
||
mediaRecorder.start();
|
||
|
||
// 显示录制状态(可以添加一个录制指示器)
|
||
console.log("Started recording audio for role:", roleIndex);
|
||
|
||
// 5秒后自动停止录制(或者可以添加手动停止按钮)
|
||
setTimeout(() => {
|
||
if (mediaRecorder.state === "recording") {
|
||
mediaRecorder.stop();
|
||
}
|
||
}, 5000);
|
||
} catch (error) {
|
||
console.error("Failed to start recording:", error);
|
||
alert("Failed to access microphone. Please check permissions.");
|
||
}
|
||
};
|
||
|
||
// 处理音频上传
|
||
const handleAudioUpload = (roleIndex: number) => {
|
||
const input = document.createElement("input");
|
||
input.type = "file";
|
||
input.accept = "audio/*";
|
||
input.onchange = (e) => {
|
||
const file = (e.target as HTMLInputElement).files?.[0];
|
||
if (file) {
|
||
// 这里可以添加实际的上传逻辑
|
||
console.log("Audio file uploaded:", file.name);
|
||
// 模拟上传成功,设置音频URL
|
||
const audioUrl = URL.createObjectURL(file);
|
||
setRoleAudios((prev) => ({ ...prev, [roleIndex]: audioUrl }));
|
||
|
||
// 保存到模板资源中
|
||
if (selectedTemplate) {
|
||
const roleName = selectedTemplate.storyRole[roleIndex];
|
||
|
||
// 查找是否已存在该角色的资源记录
|
||
const existingResourceIndex =
|
||
selectedTemplate.userResources.findIndex(
|
||
(resource) => resource.role_name === roleName
|
||
);
|
||
|
||
let updatedTemplate;
|
||
if (existingResourceIndex >= 0) {
|
||
// 如果已存在,更新现有记录
|
||
const updatedResources = [...selectedTemplate.userResources];
|
||
updatedResources[existingResourceIndex] = {
|
||
...updatedResources[existingResourceIndex],
|
||
voice_url: audioUrl,
|
||
};
|
||
updatedTemplate = {
|
||
...selectedTemplate,
|
||
userResources: updatedResources,
|
||
};
|
||
} else {
|
||
// 如果不存在,创建新记录
|
||
updatedTemplate = {
|
||
...selectedTemplate,
|
||
userResources: [
|
||
...selectedTemplate.userResources,
|
||
{
|
||
role_name: roleName,
|
||
photo_url: "",
|
||
voice_url: audioUrl,
|
||
},
|
||
],
|
||
};
|
||
}
|
||
|
||
setSelectedTemplate(updatedTemplate);
|
||
}
|
||
}
|
||
};
|
||
input.click();
|
||
};
|
||
|
||
// 删除角色音频
|
||
const handleDeleteRoleAudio = (roleIndex: number) => {
|
||
setRoleAudios((prev) => {
|
||
const newState = { ...prev };
|
||
delete newState[roleIndex];
|
||
return newState;
|
||
});
|
||
|
||
// 同时从模板资源中删除音频
|
||
if (selectedTemplate) {
|
||
const roleName = selectedTemplate.storyRole[roleIndex];
|
||
const existingResourceIndex = selectedTemplate.userResources.findIndex(
|
||
(resource) => resource.role_name === roleName
|
||
);
|
||
|
||
if (existingResourceIndex >= 0) {
|
||
const updatedResources = [...selectedTemplate.userResources];
|
||
updatedResources[existingResourceIndex] = {
|
||
...updatedResources[existingResourceIndex],
|
||
voice_url: "",
|
||
};
|
||
|
||
const updatedTemplate = {
|
||
...selectedTemplate,
|
||
userResources: updatedResources,
|
||
};
|
||
setSelectedTemplate(updatedTemplate);
|
||
}
|
||
}
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<style>{customAudioPlayerStyles}</style>
|
||
<div className="relative flex flex-col gap-4 h-24">
|
||
{/* 使用 Ant Design Tooltip */}
|
||
<Tooltip
|
||
title="Choose featured stories to act out your movie"
|
||
placement="top"
|
||
>
|
||
<div className="absolute top-0 right-0 w-4 h-4 bg-white/20 rounded-full flex items-center justify-center cursor-help">
|
||
<span className="text-[10px] text-white/70 leading-none">?</span>
|
||
</div>
|
||
</Tooltip>
|
||
{/* 模板列表 - 横向滚动 */}
|
||
<div className="flex-1 overflow-hidden">
|
||
<div className="flex gap-3 overflow-x-auto scrollbar-hide pb-2">
|
||
{loading ? (
|
||
// Loading state
|
||
<div className="flex items-center justify-center w-full h-16">
|
||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-white"></div>
|
||
</div>
|
||
) : (
|
||
// Template icon list
|
||
templates.map((template) => (
|
||
<div
|
||
key={template.id}
|
||
data-alt="template-item"
|
||
className="flex-shrink-0 cursor-pointer group"
|
||
onClick={() => handleTemplateSelect(template)}
|
||
>
|
||
{/* 模板卡片容器 */}
|
||
<div className="relative w-20 h-24 rounded-lg bg-white/[0.05] border border-white/[0.1] transition-all duration-300 hover:border-white/[0.3] overflow-hidden">
|
||
{/* 图片区域 - 占满整个容器 */}
|
||
<div className="relative w-full h-full">
|
||
<img
|
||
src={template.imageUrl}
|
||
alt={template.name}
|
||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
|
||
/>
|
||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||
</div>
|
||
|
||
{/* 鼠标悬停信息覆盖层 */}
|
||
<div className="absolute inset-0 bg-black/80 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex flex-col justify-center items-center p-2 pointer-events-none">
|
||
{/* 故事名称 */}
|
||
<p className="text-[10px] text-white/90 text-center leading-tight mb-2 font-medium">
|
||
{template.name}
|
||
</p>
|
||
{/* 角色数量 */}
|
||
<div className="text-[10px] text-white/70">
|
||
{template.storyRole.length} Characters
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 模板详情弹窗 */}
|
||
<Modal
|
||
open={isModalOpen}
|
||
onCancel={() => {
|
||
// 清空所有选中的内容数据
|
||
setRoleImages({});
|
||
setRoleAudios({});
|
||
setSelectedTemplate(null);
|
||
setSelectedRoleIndex(0);
|
||
setIsModalOpen(false);
|
||
}}
|
||
footer={null}
|
||
width="50%"
|
||
style={{ maxWidth: "600px" }}
|
||
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>
|
||
}
|
||
>
|
||
{selectedTemplate && (
|
||
<div className="rounded-2xl">
|
||
{/* 弹窗头部 */}
|
||
<div className="flex gap-4 p-4 border-b border-white/[0.1]">
|
||
{/* 左侧图片 - 减小尺寸 */}
|
||
<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.imageUrl}
|
||
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-48 pr-2">
|
||
<p
|
||
data-alt="template-description"
|
||
className="text-gray-400 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>
|
||
|
||
{/* 角色Tab切换 */}
|
||
<div className="mb-6">
|
||
<div className="flex gap-2 mb-4">
|
||
{selectedTemplate.storyRole.map((role: string, index: number) => (
|
||
<button
|
||
key={index}
|
||
data-alt={`role-tab-${index}`}
|
||
className={`px-3 py-1.5 rounded-md font-normal text-sm transition-all duration-200 ${
|
||
selectedRoleIndex === index
|
||
? "bg-blue-500/80 text-white shadow-sm"
|
||
: "bg-white/[0.05] text-white/70 hover:bg-white/[0.1] hover:text-white"
|
||
}`}
|
||
onClick={() => setSelectedRoleIndex(index)}
|
||
>
|
||
{role}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* 当前选中角色的自定义内容 */}
|
||
{selectedRoleIndex !== null && (
|
||
<div className="space-y-6">
|
||
{/* 图片上传部分 */}
|
||
<div className="space-y-3">
|
||
{/* 图片上传提示 */}
|
||
<div className="p-3 bg-gradient-to-r from-blue-600/10 to-purple-600/10 rounded-lg border border-blue-500/20">
|
||
<div className="flex items-start gap-2">
|
||
<div className="w-6 h-6 bg-blue-500/20 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
|
||
<Info className="w-3 h-3 text-blue-400" />
|
||
</div>
|
||
<div className="flex-1">
|
||
<h4 className="text-white font-medium mb-1 text-sm">
|
||
Character Photo
|
||
</h4>
|
||
<p className="text-xs text-white/80">
|
||
Upload a portrait photo to replace this character's appearance in the movie.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 图片上传区域 - 放大尺寸 */}
|
||
<div className="flex justify-center">
|
||
{roleImages[selectedRoleIndex] ? (
|
||
<div className="relative w-48 h-48">
|
||
<img
|
||
src={roleImages[selectedRoleIndex]}
|
||
alt="Character"
|
||
className="w-full h-full object-cover rounded-xl shadow-lg"
|
||
/>
|
||
<button
|
||
onClick={() => handleDeleteRoleImage(selectedRoleIndex)}
|
||
className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white rounded-full flex items-center justify-center text-sm hover:bg-red-600 transition-colors shadow-lg"
|
||
title="Delete Image"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<Upload
|
||
name="avatar"
|
||
listType="picture-card"
|
||
className="avatar-uploader [&_.ant-upload-select]:!w-80 [&_.ant-upload-select]:!h-48"
|
||
showUploadList={false}
|
||
action="https://660d2bd96ddfa2943b33731c.mockapi.io/api/upload"
|
||
beforeUpload={() => false}
|
||
onChange={(info) => {
|
||
if (info.file.status === "done") {
|
||
handleRoleImageUpload(
|
||
selectedRoleIndex,
|
||
info.file.originFileObj
|
||
);
|
||
}
|
||
}}
|
||
>
|
||
<div className="w-48 h-48 flex flex-col items-center justify-center text-white/50 transition-colors hover:text-white/70">
|
||
<UploadOutlined className="w-8 h-8 mb-2" />
|
||
<span className="text-sm">Upload Photo</span>
|
||
</div>
|
||
</Upload>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 音频部分 */}
|
||
<div className="space-y-3">
|
||
{/* 音频录制提示 */}
|
||
<div className="p-3 bg-gradient-to-r from-green-600/10 to-emerald-600/10 rounded-lg border border-green-500/20">
|
||
<div className="flex items-start gap-2">
|
||
<div className="w-6 h-6 bg-green-500/20 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
|
||
<AudioLines className="w-3 h-3 text-green-400" />
|
||
</div>
|
||
<div className="flex-1">
|
||
<h4 className="text-white font-medium mb-1 text-sm">
|
||
Character Voice
|
||
</h4>
|
||
<div className="space-y-1 text-xs text-white/80">
|
||
<p>
|
||
Record your voice to replace this character's voice in the movie.
|
||
Aim for 15 seconds of clear, natural speech.
|
||
</p>
|
||
<div className="bg-white/[0.05] rounded p-2 border border-white/[0.1] mt-1">
|
||
<p className="text-white/90 italic text-xs">
|
||
"The sun sets slowly behind the mountain, casting a warm glow over the calm valley."
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 音频操作区域 */}
|
||
<div className="space-y-4">
|
||
{/* 音频播放器或占位符 */}
|
||
{roleAudios[selectedRoleIndex] ? (
|
||
<div className="relative">
|
||
<AudioPlayer
|
||
src={roleAudios[selectedRoleIndex]}
|
||
autoPlay={false}
|
||
showJumpControls={false}
|
||
showFilledProgress={false}
|
||
showFilledVolume={true}
|
||
className="custom-audio-player"
|
||
style={{ height: "7rem",padding: "15px 20px"}}
|
||
/>
|
||
{/* 删除按钮集成到播放器中 - 更小且位于右上角 */}
|
||
<button
|
||
onClick={() => handleDeleteRoleAudio(selectedRoleIndex)}
|
||
className="absolute top-1 right-1 text-white hover:text-red-500"
|
||
title="Delete Audio"
|
||
>
|
||
<Trash2 className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div className="h-24 bg-white/[0.05] border border-white/[0.1] rounded-lg flex items-center justify-center">
|
||
<span className="text-xs text-white/40">
|
||
No Audio Recorded
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* 音频操作按钮 - 只在没有音频时显示 */}
|
||
{!roleAudios[selectedRoleIndex] && (
|
||
<div className="flex gap-3 justify-start">
|
||
{/* 录制音频按钮 */}
|
||
<Tooltip title="Record Audio" placement="top">
|
||
<button
|
||
data-alt="record-audio-button"
|
||
className="flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-green-600 to-emerald-600 text-white rounded-lg hover:from-green-700 hover:to-emerald-700 transition-all duration-200 shadow-lg hover:shadow-xl"
|
||
onClick={() => handleAudioRecord(selectedRoleIndex)}
|
||
>
|
||
<AudioLines className="w-4 h-4" />
|
||
<span className="font-medium">Record Voice</span>
|
||
</button>
|
||
</Tooltip>
|
||
|
||
{/* 上传音频按钮 */}
|
||
<Tooltip title="Upload Audio File" placement="top">
|
||
<button
|
||
data-alt="upload-audio-button"
|
||
className="flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-blue-600 to-purple-600 text-white rounded-lg hover:from-blue-700 hover:to-purple-700 transition-all duration-200 shadow-lg hover:shadow-xl"
|
||
onClick={() => handleAudioUpload(selectedRoleIndex)}
|
||
>
|
||
<UploadOutlined className="w-4 h-4" />
|
||
<span className="font-medium">Upload Audio</span>
|
||
</button>
|
||
</Tooltip>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 弹窗底部操作 - 只保留 Action 按钮 */}
|
||
<div className="flex items-center justify-end gap-3 video-storyboard-tools video-storyboard-tools-clean">
|
||
<div
|
||
className={`tool-submit-button ${loading ? "loading" : ""}`}
|
||
onClick={handleConfirm}
|
||
>
|
||
{loading ? (
|
||
<>
|
||
<Loader2 className="w-4 h-4 animate-spin" />
|
||
Actioning...
|
||
</>
|
||
) : (
|
||
<>
|
||
<ArrowUp className="w-4 h-4" />
|
||
Action
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</Modal>
|
||
</div>
|
||
</>
|
||
);
|
||
};
|
||
/**照片故事模式 */
|
||
const RenderPhotoStoryMode = ({
|
||
photoStory,
|
||
setPhotoStory,
|
||
hasGenerated,
|
||
setHasGenerated,
|
||
}: {
|
||
photoStory: Partial<ImageStoryEntity>;
|
||
setPhotoStory: React.Dispatch<
|
||
React.SetStateAction<Partial<ImageStoryEntity>>
|
||
>;
|
||
hasGenerated: boolean;
|
||
setHasGenerated: React.Dispatch<React.SetStateAction<boolean>>;
|
||
}) => {
|
||
const [isGenerating, setIsGenerating] = useState(false);
|
||
const [isUploading, setIsUploading] = useState(false);
|
||
|
||
// 故事分类选项
|
||
const storyTypes = [
|
||
{ key: "auto", label: "Auto" },
|
||
{ key: "adventure", label: "Adventure" },
|
||
{ key: "romance", label: "Romance" },
|
||
{ key: "mystery", label: "Mystery" },
|
||
{ key: "fantasy", label: "Fantasy" },
|
||
{ key: "comedy", label: "Comedy" },
|
||
];
|
||
|
||
// 处理图片上传
|
||
const handleImageUpload = async () => {
|
||
const input = document.createElement("input");
|
||
input.type = "file";
|
||
input.accept = "image/*";
|
||
input.onchange = async (e) => {
|
||
const file = (e.target as HTMLInputElement).files?.[0];
|
||
if (file) {
|
||
try {
|
||
setIsUploading(true);
|
||
// 模拟图片上传,实际项目中替换为真实API
|
||
const imageUrl = URL.createObjectURL(file);
|
||
setPhotoStory((prev) => ({ ...prev, imageUrl }));
|
||
console.log("Image uploaded successfully:", file.name);
|
||
} catch (error) {
|
||
console.error("Upload error:", error);
|
||
alert("Upload failed, please try again");
|
||
} finally {
|
||
setIsUploading(false);
|
||
}
|
||
}
|
||
};
|
||
input.click();
|
||
};
|
||
|
||
// 处理AI生成故事
|
||
const handleGenerateStory = async () => {
|
||
if (!photoStory.imageUrl || !photoStory.imageStory) {
|
||
alert("Please upload an image and enter story inspiration first");
|
||
return;
|
||
}
|
||
|
||
setIsGenerating(true);
|
||
try {
|
||
// 模拟AI生成故事,实际项目中替换为真实API
|
||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||
|
||
const generatedStory = `Based on your image and inspiration "${photoStory.imageStory}", I've created a captivating story that captures the essence of your vision. The narrative weaves together elements of mystery and wonder, creating an engaging tale that will captivate your audience.`;
|
||
|
||
setPhotoStory((prev) => ({ ...prev, imageStory: generatedStory }));
|
||
setHasGenerated(true);
|
||
} catch (error) {
|
||
console.error("Story generation failed:", error);
|
||
alert("Story generation failed, please try again");
|
||
} finally {
|
||
setIsGenerating(false);
|
||
}
|
||
};
|
||
|
||
// 处理重置
|
||
const handleReset = () => {
|
||
setPhotoStory({
|
||
imageUrl: "",
|
||
imageStory: "",
|
||
storyType: "auto",
|
||
});
|
||
setHasGenerated(false);
|
||
};
|
||
|
||
// 处理故事文本变化
|
||
const handleStoryChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||
setPhotoStory((prev) => ({ ...prev, imageStory: e.target.value }));
|
||
};
|
||
|
||
// 处理分类选择
|
||
const handleTypeChange = (value: string) => {
|
||
setPhotoStory((prev) => ({ ...prev, storyType: value }));
|
||
};
|
||
|
||
return (
|
||
<div className="relative flex flex-col gap-4 h-24">
|
||
<style>{`
|
||
.photo-story-dropdown .ant-dropdown-menu {
|
||
background: rgba(255, 255, 255, 0.15) !important;
|
||
backdrop-filter: blur(15px) !important;
|
||
border: 1px solid rgba(255, 255, 255, 0.2) !important;
|
||
border-radius: 12px !important;
|
||
padding: 8px !important;
|
||
min-width: 200px !important;
|
||
}
|
||
.photo-story-dropdown .ant-dropdown-menu-item {
|
||
padding: 12px !important;
|
||
border-radius: 8px !important;
|
||
color: rgba(255, 255, 255, 0.95) !important;
|
||
transition: all 0.3s ease !important;
|
||
}
|
||
.photo-story-dropdown .ant-dropdown-menu-item:hover {
|
||
background: rgba(255, 255, 255, 0.15) !important;
|
||
}
|
||
.photo-story-dropdown .ant-dropdown-menu-item-selected {
|
||
background: rgba(255, 255, 255, 0.15) !important;
|
||
}
|
||
`}</style>
|
||
{/* 图片上传区域 */}
|
||
<div className="flex items-center gap-4">
|
||
{/* 图片上传按钮 */}
|
||
<div
|
||
className="relative w-[72px] rounded-[6px] overflow-hidden cursor-pointer ant-dropdown-trigger"
|
||
onClick={handleImageUpload}
|
||
>
|
||
<div className="relative flex items-center justify-center w-[72px] h-[72px] rounded-[6px 6px 0 0] bg-white/[0.05] transition-all overflow-hidden hover:bg-white/[0.10]">
|
||
{isUploading ? (
|
||
<Loader2 className="w-4 h-4 animate-spin" />
|
||
) : photoStory.imageUrl ? (
|
||
<img
|
||
src={photoStory.imageUrl}
|
||
alt="Story Image"
|
||
className="w-full h-full object-cover"
|
||
/>
|
||
) : (
|
||
<Package className="w-4 h-4" />
|
||
)}
|
||
</div>
|
||
<div className="w-full h-[22px] flex items-center justify-center rounded-[0 0 6px 6px] bg-white/[0.03]">
|
||
<span className="text-xs opacity-30 cursor-[inherit]">
|
||
{isUploading
|
||
? "Uploading..."
|
||
: photoStory.imageUrl
|
||
? "Image"
|
||
: "Add Image"}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 文本输入和分类选择区域 */}
|
||
<div className="flex-1 flex flex-col gap-2 h-full">
|
||
{/* 文本输入框 */}
|
||
<textarea
|
||
value={photoStory.imageStory}
|
||
onChange={handleStoryChange}
|
||
placeholder="Describe your story inspiration"
|
||
maxLength={200}
|
||
className={`flex-1 h-full px-3 py-2 bg-white/[0.05] border border-white/[0.1] rounded-lg text-sm text-white placeholder-white/40 resize-none focus:outline-none focus:border-white/[0.3] transition-colors ${
|
||
hasGenerated ? "bg-gray-800/20" : ""
|
||
}`}
|
||
disabled={hasGenerated}
|
||
/>
|
||
</div>
|
||
|
||
{/* AI生成故事按钮或重置按钮 */}
|
||
<div className="flex-shrink-0 flex flex-col justify-between h-full">
|
||
{/* 分类选择下拉框 - 仅在未生成故事时显示 */}
|
||
{!hasGenerated && (
|
||
<Dropdown
|
||
menu={{
|
||
items: storyTypes.map((type) => ({
|
||
key: type.key,
|
||
label: type.label,
|
||
})),
|
||
onClick: ({ key }) => handleTypeChange(key),
|
||
}}
|
||
trigger={["click"]}
|
||
placement="bottomLeft"
|
||
overlayClassName="photo-story-dropdown"
|
||
>
|
||
<div className="flex items-center justify-between px-3 py-2 bg-white/[0.05] border border-white/[0.1] rounded-lg text-sm text-white cursor-pointer hover:border-white/[0.3] transition-colors">
|
||
<span>
|
||
{
|
||
storyTypes.find((t) => t.key === photoStory.storyType)
|
||
?.label
|
||
}
|
||
</span>
|
||
<ChevronDown className="w-4 h-4 opacity-50" />
|
||
</div>
|
||
</Dropdown>
|
||
)}
|
||
{/* AI生成故事按钮 */}
|
||
{!hasGenerated ? (
|
||
<Tooltip
|
||
title="AI will generate a story based on your image and text description"
|
||
placement="top"
|
||
>
|
||
<button
|
||
onClick={handleGenerateStory}
|
||
disabled={
|
||
isGenerating || !photoStory.imageUrl || !photoStory.imageStory
|
||
}
|
||
className={`px-4 py-2 rounded-lg text-sm font-medium text-white transition-all duration-200 ${
|
||
isGenerating || !photoStory.imageUrl || !photoStory.imageStory
|
||
? "opacity-50 cursor-not-allowed bg-gray-600"
|
||
: "bg-gradient-to-r from-purple-500/20 to-blue-500/20 hover:from-purple-500/30 hover:to-blue-500/30 shadow-lg hover:shadow-xl"
|
||
}`}
|
||
>
|
||
{isGenerating ? (
|
||
<>
|
||
<Loader2 className="w-4 h-4 animate-spin inline mr-2" />
|
||
Generating...
|
||
</>
|
||
) : (
|
||
"AI Generate Story"
|
||
)}
|
||
</button>
|
||
</Tooltip>
|
||
) : (
|
||
<button
|
||
onClick={handleReset}
|
||
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-gray-600 hover:bg-gray-700 transition-colors"
|
||
>
|
||
Reset
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
/**
|
||
* 视频工具面板组件
|
||
* 提供脚本输入和视频克隆两种模式,支持展开/收起功能
|
||
*/
|
||
export function ChatInputBox() {
|
||
// 控制面板展开/收起状态
|
||
const [isExpanded, setIsExpanded] = useState(false);
|
||
|
||
// 共享状态 - 需要在不同渲染函数间共享
|
||
const [videoUrl, setVideoUrl] = useState(""); // 存储上传的视频文件URL
|
||
const [isUploading, setIsUploading] = useState(false); // 视频上传过程中的加载状态
|
||
const [script, setScript] = useState(""); // 用户输入的脚本内容
|
||
const [isFocus, setIsFocus] = useState(false); // 编辑器是否处于聚焦状态,用于样式控制
|
||
const [loadingIdea, setLoadingIdea] = useState(false); // 获取创意建议时的加载状态
|
||
const [selectedMode, setSelectedMode] = useState<ModeEnum>(
|
||
ModeEnum.AUTOMATIC
|
||
); // 选择的执行模式:自动(AUTOMATIC)或手动(MANUAL)
|
||
const [selectedResolution, setSelectedResolution] = useState<ResolutionEnum>(
|
||
ResolutionEnum.HD_720P
|
||
); // 选择的视频分辨率:720P、1080P、2K、4K
|
||
const [selectedLanguage, setSelectedLanguage] = useState<string>("english"); // 选择的语言:英语、中文、日语、韩语
|
||
const [selectedVideoDuration, setSelectedVideoDuration] = useState<VideoDurationEnum>(
|
||
VideoDurationEnum.ONE_MINUTE
|
||
); // 选择的视频时长:一分钟、两分钟、三分钟
|
||
const [isCreating, setIsCreating] = useState(false); // 视频创建过程中的加载状态
|
||
|
||
// 照片故事状态 - 需要在不同渲染函数间共享
|
||
const [photoStory, setPhotoStory] = useState<Partial<ImageStoryEntity>>({
|
||
imageUrl: "",
|
||
imageStory: "",
|
||
storyType: "auto",
|
||
});
|
||
const [hasGenerated, setHasGenerated] = useState(false);
|
||
|
||
/** 渲染Clone模式:视频上传区域 */
|
||
const renderCloneMode = () => {
|
||
// 处理视频上传
|
||
const handleUploadVideo = async () => {
|
||
console.log("upload video");
|
||
const input = document.createElement("input");
|
||
input.type = "file";
|
||
input.accept = "video/*";
|
||
input.onchange = async (e) => {
|
||
const file = (e.target as HTMLInputElement).files?.[0];
|
||
if (file) {
|
||
try {
|
||
setIsUploading(true);
|
||
// 这里可以添加实际的上传逻辑
|
||
// const videoUrl = await uploadToQiniu(file, token);
|
||
// setVideoUrl(videoUrl);
|
||
console.log("视频上传成功:", file.name);
|
||
} catch (error) {
|
||
console.error("上传错误:", error);
|
||
alert("上传失败,请稍后重试");
|
||
} finally {
|
||
setIsUploading(false);
|
||
}
|
||
}
|
||
};
|
||
input.click();
|
||
};
|
||
return (
|
||
<div className="relative flex items-center gap-4 h-24">
|
||
{/* 视频上传按钮 */}
|
||
<div
|
||
className="relative w-[72px] rounded-[6px] overflow-hidden cursor-pointer ant-dropdown-trigger"
|
||
onClick={handleUploadVideo}
|
||
>
|
||
<div className="relative flex items-center justify-center w-[72px] h-[72px] rounded-[6px 6px 0 0] bg-white/[0.05] transition-all overflow-hidden hover:bg-white/[0.10]">
|
||
{isUploading ? (
|
||
<Loader2 className="w-4 h-4 animate-spin" />
|
||
) : (
|
||
<Video className="w-4 h-4" />
|
||
)}
|
||
</div>
|
||
<div className="w-full h-[22px] flex items-center justify-center rounded-[0 0 6px 6px] bg-white/[0.03]">
|
||
<span className="text-xs opacity-30 cursor-[inherit]">
|
||
{isUploading ? "上传中..." : "Add Video"}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
{/* Uploaded video preview */}
|
||
{videoUrl && (
|
||
<div className="relative w-[72px] rounded-[6px] overflow-hidden cursor-pointer ant-dropdown-trigger">
|
||
<div className="relative flex items-center justify-center w-[72px] h-[72px] rounded-[6px 6px 0 0] bg-white/[0.05] transition-all overflow-hidden hover:bg-white/[0.10]">
|
||
<video src={videoUrl} className="w-full h-full object-cover" />
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
/** Render Script mode: Script input editor */
|
||
const renderScriptMode = () => {
|
||
// Handle getting ideas
|
||
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 editor content changes
|
||
const handleEditorChange = (e: React.FormEvent<HTMLDivElement>) => {
|
||
const newText = e.currentTarget.textContent || "";
|
||
setScript(newText);
|
||
};
|
||
return (
|
||
<div className="relative flex items-center gap-4 h-24">
|
||
<div
|
||
className={`video-prompt-editor relative flex flex-1 self-stretch items-center w-0 rounded-[6px] ${
|
||
isFocus ? "focus" : ""
|
||
}`}
|
||
>
|
||
{/* 可编辑的脚本输入区域 */}
|
||
<div
|
||
className="editor-content flex-1 w-0 max-h-[78px] min-h-[26px] h-auto gap-4 pl-[10px] rounded-[10px] leading-[26px] text-sm border-none overflow-y-auto cursor-text"
|
||
contentEditable
|
||
style={{ paddingRight: "10px" }}
|
||
onFocus={() => setIsFocus(true)}
|
||
onBlur={() => setIsFocus(false)}
|
||
onInput={handleEditorChange}
|
||
suppressContentEditableWarning
|
||
>
|
||
{script}
|
||
</div>
|
||
{/* 占位符文本和获取创意按钮 */}
|
||
<div
|
||
className={`custom-placeholder absolute top-[50%] left-[10px] z-10 translate-y-[-50%] flex items-center gap-1 pointer-events-none text-[14px] leading-[26px] text-white/[0.40] ${
|
||
script ? "hidden" : "block"
|
||
}`}
|
||
>
|
||
<span>Describe the content you want to action. Get an </span>
|
||
<b
|
||
className="idea-link inline-flex items-center gap-0.5 text-white/[0.50] font-normal cursor-pointer pointer-events-auto underline"
|
||
onClick={() => handleGetIdea()}
|
||
>
|
||
{loadingIdea ? (
|
||
<Loader2 className="w-4 h-4 animate-spin" />
|
||
) : (
|
||
<>
|
||
<Lightbulb className="w-4 h-4" />
|
||
idea
|
||
</>
|
||
)}
|
||
</b>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
/** 渲染底部操作按钮区域 */
|
||
const renderBottomActions = () => {
|
||
// 处理创建视频
|
||
const handleCreateVideo = async () => {
|
||
setIsCreating(true);
|
||
// 这里可以添加实际的创建逻辑
|
||
console.log("创建视频:", {
|
||
script,
|
||
selectedMode,
|
||
selectedResolution,
|
||
selectedLanguage,
|
||
selectedVideoDuration,
|
||
});
|
||
setTimeout(() => {
|
||
setIsCreating(false);
|
||
}, 2000);
|
||
};
|
||
// 处理模式选择
|
||
const handleModeSelect: MenuProps["onClick"] = ({ key }) => {
|
||
setSelectedMode(key as ModeEnum);
|
||
};
|
||
|
||
// 处理分辨率选择
|
||
const handleResolutionSelect: MenuProps["onClick"] = ({ key }) => {
|
||
setSelectedResolution(key as ResolutionEnum);
|
||
};
|
||
|
||
// 处理语言选择
|
||
const handleLanguageSelect: MenuProps["onClick"] = ({ key }) => {
|
||
setSelectedLanguage(key as string);
|
||
};
|
||
|
||
// 处理视频时长选择
|
||
const handleVideoDurationSelect: MenuProps["onClick"] = ({ key }) => {
|
||
setSelectedVideoDuration(key as VideoDurationEnum);
|
||
};
|
||
|
||
// 下拉菜单项配置
|
||
const modeItems: MenuProps["items"] = [
|
||
{
|
||
type: "group",
|
||
label: <div className="text-white/50 text-xs px-2 pb-2">Mode</div>,
|
||
children: [
|
||
{
|
||
key: ModeEnum.AUTOMATIC,
|
||
label: (
|
||
<div className="flex flex-col gap-1 p-1">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-base font-medium">Auto</span>
|
||
</div>
|
||
<span className="text-sm text-gray-400">
|
||
Automatically execute the workflow, you can't edit the
|
||
workflow before it's finished.
|
||
</span>
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
key: ModeEnum.MANUAL,
|
||
label: (
|
||
<div className="flex flex-col gap-1 p-1">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-base font-medium">Manual</span>
|
||
<Crown className="w-4 h-4 text-yellow-500" />
|
||
</div>
|
||
<span className="text-sm text-gray-400">
|
||
Manually control the workflow, you can control the workflow
|
||
everywhere.
|
||
</span>
|
||
</div>
|
||
),
|
||
},
|
||
],
|
||
},
|
||
];
|
||
|
||
const resolutionItems: MenuProps["items"] = [
|
||
{
|
||
type: "group",
|
||
label: (
|
||
<div className="text-white/50 text-xs px-2 pb-2">Resolution</div>
|
||
),
|
||
children: [
|
||
{
|
||
key: ResolutionEnum.HD_720P,
|
||
label: (
|
||
<div className="flex items-center justify-between p-1">
|
||
<span className="text-base">720P</span>
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
key: ResolutionEnum.FULL_HD_1080P,
|
||
label: (
|
||
<div className="flex items-center justify-between p-1">
|
||
<span className="text-base">1080P</span>
|
||
<Crown className="w-4 h-4 text-yellow-500" />
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
key: ResolutionEnum.UHD_2K,
|
||
label: (
|
||
<div className="flex items-center justify-between p-1">
|
||
<span className="text-base">2K</span>
|
||
<Crown className="w-4 h-4 text-yellow-500" />
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
key: ResolutionEnum.UHD_4K,
|
||
label: (
|
||
<div className="flex items-center justify-between p-1">
|
||
<span className="text-base">4K</span>
|
||
<Crown className="w-4 h-4 text-yellow-500" />
|
||
</div>
|
||
),
|
||
},
|
||
],
|
||
},
|
||
];
|
||
|
||
const languageItems: MenuProps["items"] = [
|
||
{
|
||
type: "group",
|
||
label: <div className="text-white/50 text-xs px-2 pb-2">Language</div>,
|
||
children: [
|
||
{
|
||
key: "english",
|
||
label: (
|
||
<div className="flex items-center justify-between p-1">
|
||
<span className="text-base">English</span>
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
key: "chinese",
|
||
label: (
|
||
<div className="flex items-center justify-between p-1">
|
||
<span className="text-base">Chinese</span>
|
||
<Crown className="w-4 h-4 text-yellow-500" />
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
key: "japanese",
|
||
label: (
|
||
<div className="flex items-center justify-between p-1">
|
||
<span className="text-base">Japanese</span>
|
||
<Crown className="w-4 h-4 text-yellow-500" />
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
key: "korean",
|
||
label: (
|
||
<div className="flex items-center justify-between p-1">
|
||
<span className="text-base">Korean</span>
|
||
<Crown className="w-4 h-4 text-yellow-500" />
|
||
</div>
|
||
),
|
||
},
|
||
],
|
||
},
|
||
];
|
||
|
||
const videoDurationItems: MenuProps["items"] = [
|
||
{
|
||
type: "group",
|
||
label: <div className="text-white/50 text-xs px-2 pb-2">Video Duration</div>,
|
||
children: [
|
||
{
|
||
key: VideoDurationEnum.ONE_MINUTE,
|
||
label: (
|
||
<div className="flex items-center justify-between p-1">
|
||
<span className="text-base">One Minute</span>
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
key: VideoDurationEnum.TWO_MINUTES,
|
||
label: (
|
||
<div className="flex items-center justify-between p-1">
|
||
<span className="text-base">Two Minutes</span>
|
||
<Crown className="w-4 h-4 text-yellow-500" />
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
key: VideoDurationEnum.THREE_MINUTES,
|
||
label: (
|
||
<div className="flex items-center justify-between p-1">
|
||
<span className="text-base">Three Minutes</span>
|
||
<Crown className="w-4 h-4 text-yellow-500" />
|
||
</div>
|
||
),
|
||
},
|
||
],
|
||
},
|
||
];
|
||
|
||
return (
|
||
<div className="flex gap-3">
|
||
{/* 左侧:工具选项下拉菜单 */}
|
||
<div className="tool-scroll-box relative flex-1 w-0">
|
||
<div className="tool-scroll-box-content overflow-x-auto scrollbar-hide">
|
||
<div className="flex items-center flex-1 gap-3">
|
||
{/* 模式选择:自动/手动 */}
|
||
<Dropdown
|
||
menu={{
|
||
items: modeItems,
|
||
onClick: handleModeSelect,
|
||
selectedKeys: [selectedMode.toString()],
|
||
}}
|
||
trigger={["click"]}
|
||
overlayClassName="mode-dropdown"
|
||
placement="bottomLeft"
|
||
>
|
||
<div className="tool-operation-button ant-dropdown-trigger">
|
||
<Package className="w-4 h-4" />
|
||
<span className="text-nowrap opacity-70">
|
||
{selectedMode === ModeEnum.AUTOMATIC ? "Auto" : "Manual"}
|
||
</span>
|
||
<Crown
|
||
className={`w-4 h-4 text-yellow-500 ${
|
||
selectedMode === ModeEnum.AUTOMATIC ? "hidden" : ""
|
||
}`}
|
||
/>
|
||
</div>
|
||
</Dropdown>
|
||
|
||
{/* 分辨率选择:720P/1080P/2K/4K */}
|
||
<Dropdown
|
||
menu={{
|
||
items: resolutionItems,
|
||
onClick: handleResolutionSelect,
|
||
selectedKeys: [selectedResolution.toString()],
|
||
}}
|
||
trigger={["click"]}
|
||
overlayClassName="mode-dropdown"
|
||
placement="bottomLeft"
|
||
>
|
||
<div className="tool-operation-button ant-dropdown-trigger">
|
||
<Video className="w-4 h-4" />
|
||
<span className="text-nowrap opacity-70">
|
||
{selectedResolution === ResolutionEnum.HD_720P
|
||
? "720P"
|
||
: selectedResolution === ResolutionEnum.FULL_HD_1080P
|
||
? "1080P"
|
||
: selectedResolution === ResolutionEnum.UHD_2K
|
||
? "2K"
|
||
: "4K"}
|
||
</span>
|
||
<Crown
|
||
className={`w-4 h-4 text-yellow-500 ${
|
||
selectedResolution === ResolutionEnum.HD_720P
|
||
? "hidden"
|
||
: ""
|
||
}`}
|
||
/>
|
||
</div>
|
||
</Dropdown>
|
||
|
||
{/* 语言选择:英语/中文/日语/韩语 */}
|
||
<Dropdown
|
||
menu={{
|
||
items: languageItems,
|
||
onClick: handleLanguageSelect,
|
||
selectedKeys: [selectedLanguage.toString()],
|
||
}}
|
||
trigger={["click"]}
|
||
overlayClassName="mode-dropdown"
|
||
placement="bottomLeft"
|
||
>
|
||
<div className="tool-operation-button ant-dropdown-trigger">
|
||
<Globe className="w-4 h-4" />
|
||
<span className="text-nowrap opacity-70">
|
||
{selectedLanguage === "english"
|
||
? "English"
|
||
: selectedLanguage === "chinese"
|
||
? "Chinese"
|
||
: selectedLanguage === "japanese"
|
||
? "Japanese"
|
||
: "Korean"}
|
||
</span>
|
||
<Crown
|
||
className={`w-4 h-4 text-yellow-500 ${
|
||
selectedLanguage === "english" ? "hidden" : ""
|
||
}`}
|
||
/>
|
||
</div>
|
||
</Dropdown>
|
||
|
||
{/* 视频时长选择:一分钟/两分钟/三分钟 */}
|
||
<Dropdown
|
||
menu={{
|
||
items: videoDurationItems,
|
||
onClick: handleVideoDurationSelect,
|
||
selectedKeys: [selectedVideoDuration.toString()],
|
||
}}
|
||
trigger={["click"]}
|
||
overlayClassName="mode-dropdown"
|
||
placement="bottomLeft"
|
||
>
|
||
<div className="tool-operation-button ant-dropdown-trigger">
|
||
<Clock className="w-4 h-4" />
|
||
<span className="text-nowrap opacity-70">
|
||
{selectedVideoDuration === VideoDurationEnum.ONE_MINUTE
|
||
? "1 Min"
|
||
: selectedVideoDuration === VideoDurationEnum.TWO_MINUTES
|
||
? "2 Min"
|
||
: "3 Min"}
|
||
</span>
|
||
<Crown
|
||
className={`w-4 h-4 text-yellow-500 ${
|
||
selectedVideoDuration === VideoDurationEnum.ONE_MINUTE ? "hidden" : ""
|
||
}`}
|
||
/>
|
||
</div>
|
||
</Dropdown>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 右侧:提交按钮 */}
|
||
<div className="flex items-center gap-3">
|
||
<div
|
||
className={`tool-submit-button ${
|
||
videoUrl || script || (activeTab === "photo" && hasGenerated)
|
||
? ""
|
||
: "disabled"
|
||
} ${isCreating ? "loading" : ""}`}
|
||
onClick={isCreating ? undefined : handleCreateVideo}
|
||
>
|
||
{isCreating ? (
|
||
<>
|
||
<Loader2 className="w-4 h-4 animate-spin" />
|
||
Actioning...
|
||
</>
|
||
) : (
|
||
<>
|
||
<ArrowUp className="w-4 h-4" />
|
||
Action
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
/**
|
||
* 模块化组件
|
||
*/
|
||
const modules = {
|
||
// 想法输入
|
||
script: renderScriptMode,
|
||
// 视频克隆
|
||
clone: renderCloneMode,
|
||
// 模板故事
|
||
template: () => <RenderTemplateStoryMode />,
|
||
// 照片故事
|
||
photo: () => (
|
||
<RenderPhotoStoryMode
|
||
photoStory={photoStory}
|
||
setPhotoStory={setPhotoStory}
|
||
hasGenerated={hasGenerated}
|
||
setHasGenerated={setHasGenerated}
|
||
/>
|
||
),
|
||
} as const;
|
||
// 当前激活的标签页:'script'(脚本输入)或 'clone'(视频克隆)
|
||
const [activeTab, setActiveTab] = useState<keyof typeof modules>("script");
|
||
|
||
return (
|
||
<div className="video-tool-component relative max-w-[1080px] w-full left-[50%] translate-x-[-50%]">
|
||
{/* 视频故事板工具面板 - 毛玻璃效果背景 */}
|
||
<div className="video-storyboard-tools grid gap-4 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] grid justify-items-center place-content-center rounded-[20px] bg-[#191B1E] bg-opacity-[0.3] backdrop-blur-[1.5px] cursor-pointer"
|
||
onClick={() => setIsExpanded(false)}
|
||
>
|
||
<ChevronUp className="w-4 h-4" />
|
||
<span className="text-sm">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 className="storyboard-tools-tab relative flex gap-8 px-4 py-[10px]">
|
||
{Object.entries(modules).map(([key, module]) => {
|
||
return (
|
||
<div
|
||
className={`tab-item ${
|
||
activeTab === key ? "active" : ""
|
||
} cursor-pointer`}
|
||
onClick={() => setActiveTab(key as keyof typeof modules)}
|
||
>
|
||
<span className="text-lg opacity-60">{key}</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* 主要内容区域 - 根据展开状态动态调整高度 */}
|
||
<div
|
||
className={`flex-shrink-0 p-4 overflow-hidden transition-all duration-300 pt-0 gap-4 ${
|
||
isExpanded ? "h-[16px]" : "h-[162px]"
|
||
}`}
|
||
>
|
||
<div className="video-creation-tool-container flex flex-col gap-4">
|
||
{/* 根据当前标签页渲染对应内容 */}
|
||
{modules[activeTab]()}
|
||
{/* 底部操作按钮区域 */}
|
||
{renderBottomActions()}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|