forked from 77media/video-flow
1048 lines
38 KiB
TypeScript
1048 lines
38 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useRef } from "react";
|
||
import {
|
||
ChevronDown,
|
||
ChevronUp,
|
||
Video,
|
||
Loader2,
|
||
Lightbulb,
|
||
Package,
|
||
Crown,
|
||
Clapperboard,
|
||
Globe,
|
||
AudioLines,
|
||
Clock,
|
||
Trash2,
|
||
Plus,
|
||
LayoutTemplate,
|
||
ImagePlay,
|
||
} from "lucide-react";
|
||
import { Dropdown, Modal, Tooltip, Upload, Image } from "antd";
|
||
import { PlusOutlined, UploadOutlined, EyeOutlined } from "@ant-design/icons";
|
||
import {
|
||
StoryTemplateEntity,
|
||
} from "@/app/service/domain/Entities";
|
||
import { useTemplateStoryServiceHook } from "@/app/service/Interaction/templateStoryService";
|
||
import { useImageStoryServiceHook } from "@/app/service/Interaction/ImageStoryService";
|
||
import { useUploadFile } from "@/app/service/domain/service";
|
||
import TemplateCard from "./templateCard";
|
||
import { AudioRecorder } from "./AudioRecorder";
|
||
import { PhotoStoryMode } from "./PhotoStoryMode";
|
||
|
||
// 自定义音频播放器样式
|
||
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;
|
||
}
|
||
|
||
/* 模式选择下拉菜单样式 */
|
||
.mode-dropdown .ant-dropdown-menu {
|
||
background: rgba(255, 255, 255, 0.08) !important;
|
||
backdrop-filter: blur(20px) !important;
|
||
border: 1px solid rgba(255, 255, 255, 0.12) !important;
|
||
border-radius: 10px !important;
|
||
padding: 6px !important;
|
||
min-width: 160px !important;
|
||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3) !important;
|
||
}
|
||
|
||
.mode-dropdown .ant-dropdown-menu-item {
|
||
padding: 6px 10px !important;
|
||
border-radius: 6px !important;
|
||
color: rgba(255, 255, 255, 0.9) !important;
|
||
transition: all 0.2s ease !important;
|
||
margin-bottom: 3px !important;
|
||
}
|
||
|
||
.mode-dropdown .ant-dropdown-menu-item:hover {
|
||
background: rgba(255, 255, 255, 0.15) !important;
|
||
transform: translateX(4px) !important;
|
||
}
|
||
|
||
.mode-dropdown .ant-dropdown-menu-item:last-child {
|
||
margin-bottom: 0 !important;
|
||
}
|
||
|
||
/* 模式提示tooltip样式 */
|
||
.mode-tooltip .ant-tooltip-inner {
|
||
background: rgba(0, 0, 0, 0.8) !important;
|
||
backdrop-filter: blur(10px) !important;
|
||
border: 1px solid rgba(255, 255, 255, 0.1) !important;
|
||
border-radius: 8px !important;
|
||
color: white !important;
|
||
font-size: 12px !important;
|
||
line-height: 1.4 !important;
|
||
max-width: 200px !important;
|
||
}
|
||
|
||
.mode-tooltip .ant-tooltip-arrow::before {
|
||
background: rgba(0, 0, 0, 0.8) !important;
|
||
border: 1px solid rgba(255, 255, 255, 0.1) !important;
|
||
}
|
||
|
||
/* 模板卡片样式 */
|
||
.line-clamp-2 {
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 2;
|
||
-webkit-box-orient: vertical;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* 自定义滚动条 */
|
||
.template-list-scroll::-webkit-scrollbar {
|
||
width: 4px;
|
||
}
|
||
|
||
.template-list-scroll::-webkit-scrollbar-track {
|
||
background: rgba(255, 255, 255, 0.05);
|
||
border-radius: 2px;
|
||
}
|
||
|
||
.template-list-scroll::-webkit-scrollbar-thumb {
|
||
background: rgba(255, 255, 255, 0.2);
|
||
border-radius: 2px;
|
||
}
|
||
|
||
.template-list-scroll::-webkit-scrollbar-thumb:hover {
|
||
background: rgba(255, 255, 255, 0.3);
|
||
}
|
||
|
||
/* 自定义缩放类 */
|
||
.scale-102 {
|
||
transform: scale(1.02);
|
||
}
|
||
`;
|
||
|
||
/**模板故事模式弹窗组件 */
|
||
const RenderTemplateStoryMode = ({
|
||
isOpen,
|
||
onClose,
|
||
}: {
|
||
isOpen: boolean;
|
||
onClose: () => void;
|
||
}) => {
|
||
// Mock数据 - 直接写在组件中用于渲染
|
||
const mockTemplates = [
|
||
{
|
||
id: "1",
|
||
name: "魔法森林冒险",
|
||
generateText:
|
||
"一个关于勇敢的小女孩在魔法森林中寻找失落宝藏的奇幻冒险故事。森林中充满了神秘的生物和隐藏的危险,她必须依靠智慧和勇气来克服重重困难。",
|
||
imageUrl: [
|
||
"https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=400&h=400&fit=crop",
|
||
],
|
||
storyRole: [
|
||
{
|
||
role_name: "艾莉娅",
|
||
photo_url:
|
||
"https://images.unsplash.com/photo-1494790108755-2616b612b786?w=200&h=200&fit=crop",
|
||
voice_url: "",
|
||
},
|
||
{
|
||
role_name: "森林守护者",
|
||
photo_url:
|
||
"https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=200&h=200&fit=crop",
|
||
voice_url: "",
|
||
},
|
||
{
|
||
role_name: "魔法精灵",
|
||
photo_url:
|
||
"https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=200&h=200&fit=crop",
|
||
voice_url: "",
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "2",
|
||
name: "太空探索之旅",
|
||
generateText:
|
||
"一支勇敢的宇航员团队在探索未知星球时发现了一个古老的文明遗迹。他们必须解开这个文明的秘密,同时面对来自宇宙深处的威胁。",
|
||
imageUrl: [
|
||
"https://images.unsplash.com/photo-1446776811953-b23d0bd63bc8?w=400&h=400&fit=crop",
|
||
],
|
||
storyRole: [
|
||
{
|
||
role_name: "船长萨拉",
|
||
photo_url:
|
||
"https://images.unsplash.com/photo-1508214751196-bcfd4ca60f91?w=200&h=200&fit=crop",
|
||
voice_url: "",
|
||
},
|
||
{
|
||
role_name: "科学家马克",
|
||
photo_url:
|
||
"https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=200&h=200&fit=crop",
|
||
voice_url: "",
|
||
},
|
||
{
|
||
role_name: "工程师安娜",
|
||
photo_url:
|
||
"https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=200&h=200&fit=crop",
|
||
voice_url: "",
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "3",
|
||
name: "古代王朝传奇",
|
||
generateText:
|
||
"在一个古老的东方王朝中,年轻的公主必须学会在复杂的宫廷政治中生存。她面临着背叛、阴谋和爱情的选择,最终成长为一位明智的统治者。",
|
||
imageUrl: [
|
||
"https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=400&h=400&fit=crop",
|
||
],
|
||
storyRole: [
|
||
{
|
||
role_name: "公主明月",
|
||
photo_url:
|
||
"https://images.unsplash.com/photo-1544005313-94ddf0286df2?w=200&h=200&fit=crop",
|
||
voice_url: "",
|
||
},
|
||
{
|
||
role_name: "大将军",
|
||
photo_url:
|
||
"https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=200&h=200&fit=crop",
|
||
voice_url: "",
|
||
},
|
||
{
|
||
role_name: "宫廷谋士",
|
||
photo_url:
|
||
"https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=200&h=200&fit=crop",
|
||
voice_url: "",
|
||
},
|
||
],
|
||
},
|
||
];
|
||
|
||
// 本地状态管理
|
||
const [templates] = useState(mockTemplates);
|
||
const [selectedTemplate, setSelectedTemplate] = useState(mockTemplates[0]);
|
||
const [loading] = useState(false);
|
||
const [selectedRoleIndex, setSelectedRoleIndex] = useState<number>(0);
|
||
const [localLoading, setLocalLoading] = useState(false);
|
||
|
||
// 角色资源状态管理 - 这些是UI交互必需的,无法简化
|
||
/** 角色图片资源,key为角色索引,value为图片URL */
|
||
const [roleImages, setRoleImages] = useState<{ [key: number]: string }>({});
|
||
/** 角色音频资源,key为角色索引,value为音频URL */
|
||
const [roleAudios, setRoleAudios] = useState<{ [key: number]: string }>({});
|
||
|
||
// 处理模板选择
|
||
const handleTemplateSelect = (template: StoryTemplateEntity) => {
|
||
setSelectedTemplate(template);
|
||
};
|
||
|
||
// 处理确认操作
|
||
const handleConfirm = async () => {
|
||
if (!selectedTemplate) return;
|
||
|
||
try {
|
||
setLocalLoading(true);
|
||
// Mock actionStory函数
|
||
await new Promise((resolve) => setTimeout(resolve, 2000)); // 模拟2秒延迟
|
||
const projectId = "mock-project-" + Date.now();
|
||
console.log("Story action created:", projectId);
|
||
onClose();
|
||
setSelectedTemplate(mockTemplates[0]); // 重置为第一个模板
|
||
// 清空角色资源状态
|
||
setRoleImages({});
|
||
setRoleAudios({});
|
||
setSelectedRoleIndex(0);
|
||
} catch (error) {
|
||
console.error("Failed to create story action:", error);
|
||
alert("Failed to create story action. Please try again.");
|
||
} finally {
|
||
setLocalLoading(false);
|
||
}
|
||
};
|
||
|
||
// 处理角色图片上传
|
||
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 updatedTemplate = {
|
||
...selectedTemplate,
|
||
storyRole: selectedTemplate.storyRole.map((role: any, index: number) =>
|
||
index === roleIndex ? { ...role, photo_url: imageUrl } : role
|
||
),
|
||
};
|
||
|
||
setSelectedTemplate(updatedTemplate);
|
||
}
|
||
};
|
||
|
||
// 删除角色图片
|
||
const handleDeleteRoleImage = (roleIndex: number) => {
|
||
setRoleImages((prev) => {
|
||
const newState = { ...prev };
|
||
delete newState[roleIndex];
|
||
return newState;
|
||
});
|
||
|
||
// 同时从模板中删除图片
|
||
if (selectedTemplate) {
|
||
const updatedTemplate = {
|
||
...selectedTemplate,
|
||
storyRole: selectedTemplate.storyRole.map((role: any, index: number) =>
|
||
index === roleIndex ? { ...role, photo_url: "" } : role
|
||
),
|
||
};
|
||
setSelectedTemplate(updatedTemplate);
|
||
}
|
||
};
|
||
// 模板列表渲染
|
||
const templateListRender = () => {
|
||
return (
|
||
<div className="w-1/3 p-6 border-r border-white/[0.1]">
|
||
<h3 className="text-xl font-bold text-white mb-6">Story Templates</h3>
|
||
<div className="space-y-4 max-h-[700px] overflow-y-auto pr-3 template-list-scroll">
|
||
{templates.map((template, index) => (
|
||
<div
|
||
key={template.id}
|
||
data-alt={`template-card-${index}`}
|
||
className="flex justify-center"
|
||
onClick={() => handleTemplateSelect(template)}
|
||
>
|
||
<TemplateCard
|
||
imageUrl={template.imageUrl[0]}
|
||
imageAlt={template.name}
|
||
title={template.name}
|
||
description={template.generateText}
|
||
isSelected={selectedTemplate?.id === template.id}
|
||
width={200}
|
||
height={280}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
// 故事编辑器渲染
|
||
const storyEditorRender = () => {
|
||
return loading ? (
|
||
<div className="flex items-center justify-center h-64">
|
||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white"></div>
|
||
</div>
|
||
) : selectedTemplate ? (
|
||
<div>
|
||
{/* 模板信息头部 - 增加顶部空间 */}
|
||
<div className="flex gap-6 p-6 border-b border-white/[0.1]">
|
||
{/* 左侧图片 */}
|
||
<div className="w-1/3">
|
||
<div
|
||
data-alt="template-preview-image"
|
||
className="relative w-full aspect-square rounded-xl overflow-hidden group cursor-pointer"
|
||
>
|
||
<img
|
||
src={selectedTemplate.imageUrl[0]}
|
||
alt={selectedTemplate.name}
|
||
className="w-full h-full object-cover transition-all duration-500 group-hover:scale-105 group-hover:rotate-1"
|
||
/>
|
||
<div className="absolute inset-0 bg-gradient-to-t from-black/40 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||
</div>
|
||
</div>
|
||
|
||
{/* 右侧信息 - 增加文本渲染空间 */}
|
||
<div className="flex-1 flex flex-col">
|
||
<h2
|
||
data-alt="template-title"
|
||
className="text-2xl font-bold text-white mb-6"
|
||
>
|
||
{selectedTemplate.name}
|
||
</h2>
|
||
<div className="flex-1 overflow-y-auto max-h-80 pr-2">
|
||
<p
|
||
data-alt="template-description"
|
||
className="text-gray-300 text-base leading-relaxed"
|
||
>
|
||
{selectedTemplate.generateText}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 角色自定义部分 - 精简布局 */}
|
||
<div className="p-6">
|
||
<h3
|
||
data-alt="roles-section-title"
|
||
className="text-lg font-semibold text-white mb-4"
|
||
>
|
||
Character Customization
|
||
</h3>
|
||
|
||
{/* 紧凑布局 */}
|
||
<div className="mb-6 flex gap-4">
|
||
{/* 左侧:当前选中角色的音频与照片更改 - 精简版本 */}
|
||
<div className="flex-1 space-y-4">
|
||
{/* 图片上传部分 - 精简 */}
|
||
<div className="space-y-2 mb-8">
|
||
<div className="flex justify-center">
|
||
<Tooltip
|
||
title="Upload a portrait photo to replace this character's appearance in the movie."
|
||
placement="top"
|
||
>
|
||
<Upload
|
||
name="avatar"
|
||
listType="picture-card"
|
||
className="avatar-uploader [&_.ant-upload-select]:!w-32 [&_.ant-upload-select]:!h-32"
|
||
showUploadList={false}
|
||
action="https://660d2bd96ddfa2943b33731c.mockapi.io/api/upload"
|
||
beforeUpload={() => false}
|
||
onChange={(info) => {
|
||
if (info.file.status === "done") {
|
||
handleRoleImageUpload(
|
||
selectedRoleIndex,
|
||
info.file.originFileObj
|
||
);
|
||
}
|
||
}}
|
||
>
|
||
{roleImages[selectedRoleIndex] ||
|
||
selectedTemplate.storyRole[selectedRoleIndex]
|
||
?.photo_url ? (
|
||
<div className="relative w-32 h-32 rounded-lg overflow-hidden">
|
||
<img
|
||
src={
|
||
roleImages[selectedRoleIndex] ||
|
||
selectedTemplate.storyRole[selectedRoleIndex]
|
||
?.photo_url
|
||
}
|
||
alt="Character Portrait"
|
||
className="w-full h-full object-cover"
|
||
/>
|
||
{roleImages[selectedRoleIndex] && (
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleDeleteRoleImage(selectedRoleIndex);
|
||
}}
|
||
className="absolute top-1 right-1 w-5 h-5 bg-red-500/80 hover:bg-red-500 text-white rounded-full flex items-center justify-center transition-colors"
|
||
title="Delete Photo"
|
||
>
|
||
<Trash2 className="w-2.5 h-2.5" />
|
||
</button>
|
||
)}
|
||
<div className="absolute inset-0 bg-black/50 opacity-0 hover:opacity-100 transition-opacity duration-200 flex items-center justify-center">
|
||
<div className="text-white text-center">
|
||
<UploadOutlined className="w-4 h-4 mb-1" />
|
||
<div className="text-xs">Change Photo</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="w-32 h-32 flex flex-col items-center justify-center text-white/50 bg-white/[0.05] border border-white/[0.1] rounded-lg hover:bg-white/[0.08] transition-colors">
|
||
<UploadOutlined className="w-6 h-6 mb-1" />
|
||
<span className="text-xs">Upload Photo</span>
|
||
</div>
|
||
)}
|
||
</Upload>
|
||
</Tooltip>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 音频部分 - 精简版本 */}
|
||
<div className="space-y-2">
|
||
{/* 音频操作区域 - 使用新的 AudioRecorder 组件 */}
|
||
<div className="space-y-2">
|
||
<AudioRecorder
|
||
audioUrl={
|
||
roleAudios[selectedRoleIndex] ||
|
||
selectedTemplate.storyRole[selectedRoleIndex]?.voice_url
|
||
}
|
||
onAudioRecorded={(audioBlob, audioUrl) => {
|
||
// 保存到状态
|
||
setRoleAudios((prev) => ({
|
||
...prev,
|
||
[selectedRoleIndex]: audioUrl,
|
||
}));
|
||
|
||
// 保存到模板中
|
||
if (selectedTemplate) {
|
||
const updatedTemplate = {
|
||
...selectedTemplate,
|
||
storyRole: selectedTemplate.storyRole.map(
|
||
(role: any, index: number) =>
|
||
index === selectedRoleIndex
|
||
? { ...role, voice_url: audioUrl }
|
||
: role
|
||
),
|
||
};
|
||
setSelectedTemplate(updatedTemplate);
|
||
}
|
||
}}
|
||
onAudioDeleted={() => {
|
||
// 从状态中删除
|
||
setRoleAudios((prev) => {
|
||
const newState = { ...prev };
|
||
delete newState[selectedRoleIndex];
|
||
return newState;
|
||
});
|
||
|
||
// 从模板中删除
|
||
if (selectedTemplate) {
|
||
const updatedTemplate = {
|
||
...selectedTemplate,
|
||
storyRole: selectedTemplate.storyRole.map(
|
||
(role: any, index: number) =>
|
||
index === selectedRoleIndex
|
||
? { ...role, voice_url: "" }
|
||
: role
|
||
),
|
||
};
|
||
setSelectedTemplate(updatedTemplate);
|
||
}
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 右侧:角色图片缩略图列表 - 精简 */}
|
||
<div className="w-24 space-y-2">
|
||
<h4 className="text-white/70 text-xs font-medium mb-2">
|
||
Characters
|
||
</h4>
|
||
{selectedTemplate.storyRole.map((role, index: number) => (
|
||
<Tooltip key={index} title={role.role_name} placement="left">
|
||
<button
|
||
data-alt={`character-thumbnail-${index}`}
|
||
className={`w-full aspect-square rounded-lg overflow-hidden border-2 transition-all duration-200 hover:scale-105 ${
|
||
selectedRoleIndex === index
|
||
? "border-blue-500 shadow-lg shadow-blue-500/30"
|
||
: "border-white/20 hover:border-white/40"
|
||
}`}
|
||
onClick={() => setSelectedRoleIndex(index)}
|
||
>
|
||
<img
|
||
src={role.photo_url}
|
||
alt={role.role_name}
|
||
className="w-full h-full object-cover"
|
||
/>
|
||
</button>
|
||
</Tooltip>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 弹窗底部操作 - 只保留 Action 按钮 */}
|
||
<div className="relative group flex justify-end mt-10">
|
||
<div className="relative w-40 h-12 opacity-90 overflow-hidden rounded-xl bg-black z-10">
|
||
<div className="absolute z-10 -translate-x-32 group-hover:translate-x-[20rem] ease-in transistion-all duration-700 h-full w-32 bg-gradient-to-r from-gray-500 to-white/10 opacity-30 -skew-x-12"></div>
|
||
|
||
<div className="absolute flex items-center justify-center text-white z-[1] opacity-90 rounded-2xl inset-0.5 bg-black">
|
||
<button
|
||
name="text"
|
||
className="input font-semibold text-base h-full opacity-90 w-full px-10 py-2 rounded-xl bg-black flex items-center justify-center"
|
||
onClick={handleConfirm}
|
||
>
|
||
{localLoading ? (
|
||
<>
|
||
<Loader2 className="w-3 h-3 animate-spin" />
|
||
Actioning...
|
||
</>
|
||
) : (
|
||
<>
|
||
<Clapperboard className="w-5 h-5" />
|
||
Action
|
||
</>
|
||
)}
|
||
</button>
|
||
</div>
|
||
<div className="absolute duration-1000 group-hover:animate-spin w-full h-[80px] bg-gradient-to-r from-[rgb(106,244,249)] to-[rgb(199,59,255)] blur-[30px]"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<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 (
|
||
<>
|
||
<style>{customAudioPlayerStyles}</style>
|
||
<Modal
|
||
open={isOpen}
|
||
onCancel={() => {
|
||
// 清空所有选中的内容数据
|
||
setRoleImages({});
|
||
setRoleAudios({});
|
||
setSelectedTemplate(mockTemplates[0]); // 重置为第一个模板
|
||
setSelectedRoleIndex(0);
|
||
onClose();
|
||
}}
|
||
footer={null}
|
||
width="60%"
|
||
style={{ maxWidth: "800px" }}
|
||
className="template-modal"
|
||
closeIcon={
|
||
<div className="w-6 h-6 bg-white/10 rounded-full flex items-center justify-center hover:bg-white/20 transition-colors">
|
||
<span className="text-white/70 text-lg leading-none flex items-center justify-center">
|
||
×
|
||
</span>
|
||
</div>
|
||
}
|
||
>
|
||
<div className="rounded-2xl">
|
||
{/* 弹窗头部 */}
|
||
<div className="flex gap-4 p-4 border-b border-white/[0.1]">
|
||
<h2 className="text-2xl font-bold text-white">
|
||
Template Story Selection
|
||
</h2>
|
||
</div>
|
||
<div className="flex gap-4">
|
||
{templateListRender()}
|
||
<div className="flex-1">{storyEditorRender()}</div>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
</>
|
||
);
|
||
};
|
||
|
||
/**
|
||
* 视频工具面板组件
|
||
* 提供脚本输入和视频克隆两种模式,支持展开/收起功能
|
||
*/
|
||
export function ChatInputBox() {
|
||
// 控制面板展开/收起状态
|
||
const [isExpanded, setIsExpanded] = useState(false);
|
||
|
||
// 模板故事弹窗状态
|
||
const [isTemplateModalOpen, setIsTemplateModalOpen] = useState(false);
|
||
|
||
// 图片故事模式状态
|
||
const [isPhotoStoryMode, setIsPhotoStoryMode] = useState(false);
|
||
|
||
// 使用图片故事服务hook
|
||
const {
|
||
activeImageUrl,
|
||
activeTextContent,
|
||
isAnalyzing,
|
||
isUploading: isImageUploading,
|
||
uploadAndAnalyzeImage,
|
||
updateStoryContent,
|
||
resetImageStory,
|
||
} = useImageStoryServiceHook();
|
||
|
||
// 使用上传文件Hook
|
||
const { uploadFile, isUploading } = useUploadFile();
|
||
|
||
// 共享状态 - 需要在不同渲染函数间共享
|
||
const [script, setScript] = useState(""); // 用户输入的脚本内容
|
||
const [loadingIdea, setLoadingIdea] = useState(false); // 获取创意建议时的加载状态
|
||
const [isCreating, setIsCreating] = useState(false); // 视频创建过程中的加载状态
|
||
|
||
// 配置选项状态 - 整合所有配置项到一个对象
|
||
const [configOptions, setConfigOptions] = useState({
|
||
mode: "auto",
|
||
resolution: "720p",
|
||
language: "english",
|
||
videoDuration: "1min",
|
||
});
|
||
|
||
|
||
|
||
// 处理图片上传和分析
|
||
const handlePhotoUpload = async (file: File) => {
|
||
try {
|
||
// 先上传图片到服务器
|
||
const uploadedImageUrl = await uploadFile(file, (progress) => {
|
||
console.log("上传进度:", progress);
|
||
});
|
||
|
||
// 使用hook处理图片上传和分析
|
||
await uploadAndAnalyzeImage(uploadedImageUrl);
|
||
|
||
// 等待一小段时间确保hook状态更新完成,然后同步到输入框
|
||
setTimeout(() => {
|
||
setScript(activeTextContent);
|
||
}, 100);
|
||
} catch (error) {
|
||
console.error("Photo upload or analysis failed:", error);
|
||
}
|
||
};
|
||
|
||
// 退出图片故事模式
|
||
const exitPhotoStoryMode = () => {
|
||
setIsPhotoStoryMode(false);
|
||
resetImageStory();
|
||
};
|
||
|
||
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);
|
||
|
||
// 如果在图片故事模式下,同步更新hook中的故事内容
|
||
if (isPhotoStoryMode) {
|
||
updateStoryContent(newText);
|
||
}
|
||
};
|
||
|
||
// 当进入图片故事模式时,清空输入框内容并直接触发文件选择
|
||
const enterPhotoStoryMode = () => {
|
||
setIsPhotoStoryMode(true);
|
||
setScript("");
|
||
resetImageStory();
|
||
|
||
// 直接触发文件选择器
|
||
const fileInput = document.createElement("input");
|
||
fileInput.type = "file";
|
||
fileInput.accept = "image/*";
|
||
fileInput.style.display = "none";
|
||
|
||
fileInput.onchange = (e) => {
|
||
const target = e.target as HTMLInputElement;
|
||
if (target.files && target.files[0]) {
|
||
handlePhotoUpload(target.files[0]);
|
||
}
|
||
// 清理DOM
|
||
document.body.removeChild(fileInput);
|
||
};
|
||
|
||
document.body.appendChild(fileInput);
|
||
fileInput.click();
|
||
};
|
||
|
||
// Handle creating video
|
||
const handleCreateVideo = async () => {
|
||
setIsCreating(true);
|
||
// 这里可以添加实际的创建逻辑
|
||
console.log("创建视频:", {
|
||
script,
|
||
...configOptions,
|
||
...(isPhotoStoryMode && {
|
||
imageUrl: activeImageUrl,
|
||
imageStory: activeTextContent,
|
||
isAnalyzing,
|
||
isUploading: isImageUploading
|
||
}),
|
||
});
|
||
setTimeout(() => {
|
||
setIsCreating(false);
|
||
}, 2000);
|
||
};
|
||
|
||
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
|
||
className={`flex items-center justify-center p-1 transition-all duration-300 ${
|
||
isExpanded ? "h-[16px]" : "h-[60px]"
|
||
}`}
|
||
>
|
||
{/* 输入框和Action按钮 - 只在展开状态显示 */}
|
||
{!isExpanded && (
|
||
<div className="flex items-center gap-3 w-full pl-3">
|
||
{/* 模式选择下拉菜单 */}
|
||
<Dropdown
|
||
menu={{
|
||
items: [
|
||
{
|
||
key: "template",
|
||
label: (
|
||
<div className="flex items-center gap-3 px-3 py-2">
|
||
<LayoutTemplate className="w-5 h-5 text-white/80" />
|
||
<span className="text-sm text-white/90">
|
||
Template Story
|
||
</span>
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
key: "photo",
|
||
label: (
|
||
<div className="flex items-center gap-3 px-3 py-2">
|
||
<ImagePlay className="w-5 h-5 text-white/80" />
|
||
<span className="text-sm text-white/90">
|
||
Photo Story
|
||
</span>
|
||
</div>
|
||
),
|
||
},
|
||
],
|
||
onClick: ({ key }) => {
|
||
if (key === "template") {
|
||
setIsTemplateModalOpen(true);
|
||
} else if (key === "photo") {
|
||
enterPhotoStoryMode();
|
||
}
|
||
console.log("Selected mode:", key);
|
||
},
|
||
}}
|
||
trigger={["click"]}
|
||
placement="topLeft"
|
||
overlayClassName="mode-dropdown"
|
||
>
|
||
<button
|
||
data-alt="mode-selector"
|
||
className="flex items-center justify-center w-10 h-10 bg-white/[0.08] backdrop-blur-xl border border-white/[0.12] rounded-lg text-white/80 hover:bg-white/[0.12] hover:border-white/[0.2] transition-all duration-200 shadow-[0_4px_16px_rgba(0,0,0,0.2)]"
|
||
>
|
||
<Plus className="w-4 h-4" />
|
||
</button>
|
||
</Dropdown>
|
||
|
||
{/* 图片故事模式UI */}
|
||
{isPhotoStoryMode && <PhotoStoryMode />}
|
||
|
||
<div className="video-prompt-editor relative flex flex-1 items-center rounded-[6px]">
|
||
{/* 可编辑的脚本输入区域 */}
|
||
<div
|
||
className={`editor-content flex-1 w-0 max-h-[48px] min-h-[40px] h-auto pl-[10px] pr-[10px] py-[10px] rounded-[10px] leading-[20px] text-sm border-none overflow-y-auto cursor-text flex items-center ${
|
||
isPhotoStoryMode && isAnalyzing ? "opacity-50" : ""
|
||
}`}
|
||
contentEditable={!isAnalyzing}
|
||
onInput={handleEditorChange}
|
||
suppressContentEditableWarning
|
||
>
|
||
{isPhotoStoryMode && isAnalyzing ? (
|
||
<div className="flex items-center gap-2 text-white/60">
|
||
<Loader2 className="w-4 h-4 animate-spin" />
|
||
<span>Analyzing your image, please wait...</span>
|
||
</div>
|
||
) : isPhotoStoryMode && activeTextContent ? (
|
||
<div className="text-white/90">{activeTextContent}</div>
|
||
) : (
|
||
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-[20px] text-white/[0.40] ${
|
||
script ||
|
||
(isPhotoStoryMode && isAnalyzing) ||
|
||
(isPhotoStoryMode && activeTextContent)
|
||
? "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>
|
||
|
||
{/* Action按钮 */}
|
||
<div className="relative group">
|
||
<div className="relative w-40 h-12 opacity-90 overflow-hidden rounded-xl bg-black z-10">
|
||
<div className="absolute z-10 -translate-x-32 group-hover:translate-x-[20rem] ease-in transistion-all duration-700 h-full w-32 bg-gradient-to-r from-gray-500 to-white/10 opacity-30 -skew-x-12"></div>
|
||
|
||
<div className="absolute flex items-center justify-center text-white z-[1] opacity-90 rounded-2xl inset-0.5 bg-black">
|
||
<button
|
||
name="text"
|
||
className="input font-semibold text-base h-full opacity-90 w-full px-10 py-2 rounded-xl bg-black flex items-center justify-center"
|
||
onClick={isCreating ? undefined : handleCreateVideo}
|
||
>
|
||
{isCreating ? (
|
||
<>
|
||
<Loader2 className="w-3 h-3 animate-spin" />
|
||
Actioning...
|
||
</>
|
||
) : (
|
||
<>
|
||
<Clapperboard className="w-5 h-5" />
|
||
Action
|
||
</>
|
||
)}
|
||
</button>
|
||
</div>
|
||
<div className="absolute duration-1000 group-hover:animate-spin w-full h-[80px] bg-gradient-to-r from-[rgb(106,244,249)] to-[rgb(199,59,255)] blur-[30px]"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 配置选项区域 */}
|
||
<ConfigOptions
|
||
config={configOptions}
|
||
onConfigChange={(key, value) =>
|
||
setConfigOptions((prev) => ({ ...prev, [key]: value }))
|
||
}
|
||
/>
|
||
|
||
{/* 模板故事弹窗 */}
|
||
<RenderTemplateStoryMode
|
||
isOpen={isTemplateModalOpen}
|
||
onClose={() => setIsTemplateModalOpen(false)}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 配置选项组件
|
||
* 提供视频创建的各种配置选项,位于输入框下方
|
||
*/
|
||
const ConfigOptions = ({
|
||
config,
|
||
onConfigChange,
|
||
}: {
|
||
config: {
|
||
mode: string;
|
||
resolution: string;
|
||
language: string;
|
||
videoDuration: string;
|
||
};
|
||
onConfigChange: (key: string, value: string) => void;
|
||
}) => {
|
||
const configItems = [
|
||
{
|
||
key: "mode",
|
||
icon: Package,
|
||
options: [
|
||
{ value: "auto", label: "Auto", isVip: false },
|
||
{ value: "manual", label: "Manual", isVip: true },
|
||
],
|
||
},
|
||
{
|
||
key: "resolution",
|
||
icon: Video,
|
||
options: [
|
||
{ value: "720p", label: "720P", isVip: false },
|
||
{ value: "1080p", label: "1080P", isVip: true },
|
||
{ value: "2k", label: "2K", isVip: true },
|
||
{ value: "4k", label: "4K", isVip: true },
|
||
],
|
||
},
|
||
{
|
||
key: "language",
|
||
icon: Globe,
|
||
options: [
|
||
{ value: "english", label: "English", isVip: false },
|
||
{ value: "chinese", label: "Chinese", isVip: true },
|
||
{ value: "japanese", label: "Japanese", isVip: true },
|
||
{ value: "korean", label: "Korean", isVip: true },
|
||
],
|
||
},
|
||
{
|
||
key: "videoDuration",
|
||
icon: Clock,
|
||
options: [
|
||
{ value: "1min", label: "1 Min", isVip: false },
|
||
{ value: "2min", label: "2 Min", isVip: true },
|
||
{ value: "3min", label: "3 Min", isVip: true },
|
||
],
|
||
},
|
||
];
|
||
|
||
return (
|
||
<div className="flex items-center gap-3 mt-3">
|
||
{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="bottomLeft"
|
||
>
|
||
<button
|
||
data-alt={`config-${item.key}`}
|
||
className="flex items-center gap-2 px-2.5 py-1.5 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"
|
||
>
|
||
<IconComponent className="w-3.5 h-3.5" />
|
||
<span className="text-xs">{currentOption?.label}</span>
|
||
{currentOption?.isVip && (
|
||
<Crown className="w-2.5 h-2.5 text-yellow-500" />
|
||
)}
|
||
</button>
|
||
</Dropdown>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
};
|