forked from 77media/video-flow
967 lines
33 KiB
TypeScript
967 lines
33 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
useState,
|
||
useRef,
|
||
useEffect,
|
||
forwardRef,
|
||
useImperativeHandle,
|
||
} 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 { useImageStoryServiceHook } from "@/app/service/Interaction/ImageStoryService";
|
||
import TemplateCard from "./templateCard";
|
||
import { AudioRecorder } from "./AudioRecorder";
|
||
import { useTemplateStoryServiceHook } from "@/app/service/Interaction/templateStoryService";
|
||
|
||
// 自定义音频播放器样式
|
||
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;
|
||
}) => {
|
||
// 使用 hook 管理状态
|
||
const {
|
||
templateStoryList,
|
||
selectedTemplate,
|
||
activeRoleIndex,
|
||
activeRole,
|
||
isLoading,
|
||
getTemplateStoryList,
|
||
actionStory,
|
||
setSelectedTemplate,
|
||
setActiveRoleIndex,
|
||
setActiveRoleImage,
|
||
setActiveRoleAudio,
|
||
} = useTemplateStoryServiceHook();
|
||
|
||
// 本地加载状态,用于 UI 反馈
|
||
const [localLoading, setLocalLoading] = useState(false);
|
||
|
||
// 组件挂载时获取模板列表
|
||
useEffect(() => {
|
||
if (isOpen) {
|
||
getTemplateStoryList();
|
||
}
|
||
}, [isOpen, getTemplateStoryList]);
|
||
|
||
// 处理模板选择
|
||
const handleTemplateSelect = (template: StoryTemplateEntity) => {
|
||
setSelectedTemplate(template);
|
||
setActiveRoleIndex(0); // 重置角色选择
|
||
};
|
||
|
||
// 处理确认操作
|
||
const handleConfirm = async () => {
|
||
if (!selectedTemplate) return;
|
||
|
||
try {
|
||
setLocalLoading(true);
|
||
const projectId = await actionStory();
|
||
console.log("Story action created:", projectId);
|
||
onClose();
|
||
// 重置状态
|
||
setSelectedTemplate(null);
|
||
setActiveRoleIndex(0);
|
||
} catch (error) {
|
||
console.error("Failed to create story action:", error);
|
||
// 这里可以添加 toast 提示
|
||
} finally {
|
||
setLocalLoading(false);
|
||
}
|
||
};
|
||
|
||
// 处理角色图片上传
|
||
const handleRoleImageUpload = (roleIndex: number, file: any) => {
|
||
if (file && selectedTemplate) {
|
||
// 模拟上传成功,设置图片URL
|
||
const imageUrl = URL.createObjectURL(file);
|
||
setActiveRoleImage(imageUrl);
|
||
}
|
||
};
|
||
|
||
// 删除角色图片
|
||
const handleDeleteRoleImage = (roleIndex: number) => {
|
||
if (selectedTemplate) {
|
||
setActiveRoleImage("");
|
||
}
|
||
};
|
||
// 模板列表渲染
|
||
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">
|
||
{templateStoryList.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 isLoading ? (
|
||
<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(
|
||
activeRoleIndex,
|
||
info.file.originFileObj
|
||
);
|
||
}
|
||
}}
|
||
>
|
||
{activeRole?.photo_url ? (
|
||
<div className="relative w-32 h-32 rounded-lg overflow-hidden">
|
||
<img
|
||
src={activeRole.photo_url}
|
||
alt="Character Portrait"
|
||
className="w-full h-full object-cover"
|
||
/>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleDeleteRoleImage(activeRoleIndex);
|
||
}}
|
||
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={activeRole?.voice_url || ""}
|
||
onAudioRecorded={(audioBlob, audioUrl) => {
|
||
setActiveRoleAudio(audioUrl);
|
||
}}
|
||
onAudioDeleted={() => {
|
||
setActiveRoleAudio("");
|
||
}}
|
||
/>
|
||
</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 ${
|
||
activeRoleIndex === index
|
||
? "border-blue-500 shadow-lg shadow-blue-500/30"
|
||
: "border-white/20 hover:border-white/40"
|
||
}`}
|
||
onClick={() => setActiveRoleIndex(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={() => {
|
||
// 清空所有选中的内容数据
|
||
setSelectedTemplate(null);
|
||
setActiveRoleIndex(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);
|
||
|
||
// 共享状态 - 需要在不同渲染函数间共享
|
||
const [script, setScript] = useState(""); // 用户输入的脚本内容
|
||
|
||
// 子组件引用,用于调用子组件方法
|
||
const photoStoryModeRef = useRef<{
|
||
enterPhotoStoryMode: () => void;
|
||
getStoryContent: () => string;
|
||
}>(null);
|
||
|
||
// 响应式管理输入框内容:当在图片故事模式下,从子组件获取故事内容
|
||
useEffect(() => {
|
||
if (isPhotoStoryMode && photoStoryModeRef.current) {
|
||
const storyContent = photoStoryModeRef.current.getStoryContent();
|
||
if (storyContent) {
|
||
setScript(storyContent);
|
||
}
|
||
}
|
||
}, [isPhotoStoryMode]);
|
||
|
||
const [loadingIdea, setLoadingIdea] = useState(false); // 获取创意建议时的加载状态
|
||
const [isCreating, setIsCreating] = useState(false); // 视频创建过程中的加载状态
|
||
|
||
// 配置选项状态 - 整合所有配置项到一个对象
|
||
const [configOptions, setConfigOptions] = useState({
|
||
mode: "auto",
|
||
resolution: "720p",
|
||
language: "english",
|
||
videoDuration: "1min",
|
||
});
|
||
|
||
// 退出图片故事模式
|
||
const exitPhotoStoryMode = () => {
|
||
setIsPhotoStoryMode(false);
|
||
};
|
||
|
||
const handleGetIdea = () => {
|
||
if (loadingIdea) return;
|
||
setLoadingIdea(true);
|
||
const ideaText =
|
||
"a cute capybara with an orange on its head, staring into the distance and walking forward";
|
||
setTimeout(() => {
|
||
setScript(ideaText);
|
||
setLoadingIdea(false);
|
||
}, 3000);
|
||
};
|
||
|
||
// Handle editor content changes
|
||
const handleEditorChange = (e: React.FormEvent<HTMLDivElement>) => {
|
||
const newText = e.currentTarget.textContent || "";
|
||
setScript(newText);
|
||
};
|
||
|
||
// 当进入图片故事模式时,调用子组件方法
|
||
const enterPhotoStoryMode = () => {
|
||
setIsPhotoStoryMode(true);
|
||
|
||
// 调用子组件的进入方法
|
||
if (photoStoryModeRef.current) {
|
||
photoStoryModeRef.current.enterPhotoStoryMode();
|
||
}
|
||
};
|
||
|
||
// Handle creating video
|
||
const handleCreateVideo = async () => {
|
||
setIsCreating(true);
|
||
// 这里可以添加实际的创建逻辑
|
||
console.log("创建视频:", {
|
||
script,
|
||
...configOptions,
|
||
...(isPhotoStoryMode && {
|
||
// 从子组件获取图片故事相关信息
|
||
isPhotoStoryMode: true,
|
||
}),
|
||
});
|
||
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 - 始终渲染,通过ref调用方法 */}
|
||
<PhotoStoryMode
|
||
ref={photoStoryModeRef}
|
||
onExitMode={exitPhotoStoryMode}
|
||
isVisible={isPhotoStoryMode}
|
||
/>
|
||
|
||
<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"
|
||
contentEditable={true}
|
||
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-[20px] 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>
|
||
|
||
{/* 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>
|
||
);
|
||
};
|
||
|
||
/**
|
||
* 图片故事模式组件
|
||
* 显示图片预览、分析状态和故事类型选择器
|
||
* 使用自己的hook管理状态,通过ref暴露方法给父组件
|
||
*/
|
||
const PhotoStoryMode = forwardRef<
|
||
{
|
||
enterPhotoStoryMode: () => void;
|
||
getStoryContent: () => string;
|
||
},
|
||
{
|
||
onExitMode: () => void;
|
||
isVisible: boolean;
|
||
}
|
||
>(({ onExitMode, isVisible }, ref) => {
|
||
// 使用图片故事服务hook管理自己的状态
|
||
const {
|
||
activeImageUrl,
|
||
selectedCategory,
|
||
isAnalyzing,
|
||
isUploading,
|
||
storyTypeOptions,
|
||
analyzedStoryContent,
|
||
updateStoryType,
|
||
resetImageStory,
|
||
updateStoryContent,
|
||
triggerFileSelectionAndAnalyze,
|
||
} = useImageStoryServiceHook();
|
||
|
||
// 通过ref暴露方法给父组件
|
||
useImperativeHandle(
|
||
ref,
|
||
() => ({
|
||
enterPhotoStoryMode: () => {
|
||
// 触发文件选择和分析
|
||
triggerFileSelectionAndAnalyze().catch((error: unknown) => {
|
||
console.error("Failed to enter photo story mode:", error);
|
||
});
|
||
},
|
||
getStoryContent: () => analyzedStoryContent || "",
|
||
}),
|
||
[triggerFileSelectionAndAnalyze, analyzedStoryContent]
|
||
);
|
||
|
||
// 处理退出模式
|
||
const handleExitMode = () => {
|
||
resetImageStory();
|
||
onExitMode();
|
||
};
|
||
|
||
// 如果不显示,返回null
|
||
if (!isVisible) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<div className="absolute top-[-110px] left-14 right-0 flex items-center justify-between">
|
||
{/* 左侧:图片预览区域和分析状态指示器 */}
|
||
<div className="flex items-center gap-3">
|
||
{/* 图片预览区域 - 使用Ant Design Image组件 */}
|
||
{activeImageUrl && (
|
||
<div className="relative w-24 h-24 rounded-lg overflow-hidden bg-white/[0.05] border border-white/[0.1] shadow-[0_4px_16px_rgba(0,0,0,0.2)]">
|
||
<Image
|
||
src={activeImageUrl}
|
||
alt="Story inspiration"
|
||
className="w-full h-full object-cover"
|
||
preview={{
|
||
mask: <EyeOutlined className="w-6 h-6 text-white/80" />,
|
||
maskClassName:
|
||
"flex items-center justify-center bg-black/50 hover:bg-black/70 transition-colors",
|
||
}}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* 删除图片按钮 - 简洁样式 */}
|
||
{activeImageUrl && (
|
||
<button
|
||
onClick={handleExitMode}
|
||
className="absolute -top-2 left-24 w-6 h-6 bg-black/60 hover:bg-black/80 rounded-full flex items-center justify-center text-white/80 hover:text-white transition-all duration-200 z-10"
|
||
title="删除图片并退出图片故事模式"
|
||
>
|
||
<Trash2 className="w-2.5 h-2.5" />
|
||
</button>
|
||
)}
|
||
|
||
{/* 分析状态指示器 */}
|
||
{isAnalyzing && (
|
||
<div className="flex items-center gap-2 px-3 py-2 bg-white/[0.1] rounded-lg">
|
||
<Loader2 className="w-4 h-4 animate-spin text-white/80" />
|
||
<span className="text-sm text-white/80">
|
||
{isUploading ? "Uploading image..." : "Analyzing image..."}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 右侧:故事类型选择器 */}
|
||
{activeImageUrl && (
|
||
<Dropdown
|
||
menu={{
|
||
items: storyTypeOptions.map((type) => ({
|
||
key: type.key,
|
||
label: (
|
||
<div className="px-3 py-2 text-sm text-white/90">
|
||
{type.label}
|
||
</div>
|
||
),
|
||
})),
|
||
onClick: ({ key }) => updateStoryType(key),
|
||
}}
|
||
trigger={["click"]}
|
||
placement="bottomRight"
|
||
>
|
||
<button className="px-3 py-2 bg-white/[0.1] hover:bg-white/[0.15] border border-white/[0.2] rounded-lg text-white/80 text-sm transition-colors flex items-center gap-2">
|
||
<span>
|
||
{storyTypeOptions.find((t) => t.key === selectedCategory)
|
||
?.label || "Auto"}
|
||
</span>
|
||
<ChevronDown className="w-3 h-3" />
|
||
</button>
|
||
</Dropdown>
|
||
)}
|
||
</div>
|
||
);
|
||
});
|