video-flow-b/components/common/ChatInputBox.tsx

1528 lines
58 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, 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>
);
}