forked from 77media/video-flow
511 lines
20 KiB
TypeScript
511 lines
20 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect, useRef } from "react";
|
||
import {
|
||
ChevronDown,
|
||
ChevronUp,
|
||
Video,
|
||
Loader2,
|
||
Lightbulb,
|
||
Package,
|
||
Crown,
|
||
Clapperboard,
|
||
Globe,
|
||
Clock,
|
||
Trash2,
|
||
LayoutTemplate,
|
||
ImagePlay,
|
||
Sparkles,
|
||
Settings,
|
||
MoreHorizontal,
|
||
} from "lucide-react";
|
||
import {
|
||
Dropdown,
|
||
Modal,
|
||
Tooltip,
|
||
Upload,
|
||
Popconfirm,
|
||
Image,
|
||
Popover,
|
||
Switch,
|
||
} from "antd";
|
||
import { UploadOutlined } from "@ant-design/icons";
|
||
import { StoryTemplateEntity } from "@/app/service/domain/Entities";
|
||
import { useImageStoryServiceHook } from "@/app/service/Interaction/ImageStoryService";
|
||
import TemplateCard from "./templateCard";
|
||
import { AudioRecorder } from "./AudioRecorder";
|
||
import { useTemplateStoryServiceHook } from "@/app/service/Interaction/templateStoryService";
|
||
import { useRouter } from "next/navigation";
|
||
import { createMovieProjectV1 } from "@/api/video_flow";
|
||
import {
|
||
MovieProjectService,
|
||
MovieProjectMode,
|
||
} from "@/app/service/Interaction/MovieProjectService";
|
||
import { useLoadScriptText, useUploadFile } from "@/app/service/domain/service";
|
||
import { ActionButton } from "../common/ActionButton";
|
||
import { HighlightEditor } from "../common/HighlightEditor";
|
||
import GlobalLoad from "../common/GlobalLoad";
|
||
import { useDeviceType } from '@/hooks/useDeviceType';
|
||
import { PcTemplateModal } from "./PcTemplateModal";
|
||
import { H5TemplateDrawer } from "./H5TemplateDrawer";
|
||
import { PcPhotoStoryModal } from "./PcPhotoStoryModal";
|
||
import { H5PhotoStoryDrawer } from "./H5PhotoStoryDrawer";
|
||
|
||
const LauguageOptions = [
|
||
{ value: "english", label: "English", isVip: false, code:'EN' },
|
||
{ value: "chinese", label: "Chinese", isVip: false, code:'ZH' },
|
||
{ value: "japanese", label: "Japanese", isVip: false, code:'JA' },
|
||
{ value: "spanish", label: "Spanish", isVip: false, code:'ES' },
|
||
{ value: "portuguese", label: "Portuguese", isVip: false, code:'PT' },
|
||
{ value: "hindi", label: "Hindi", isVip: false, code:'HI' },
|
||
{ value: "korean", label: "Korean", isVip: false, code:'KO' },
|
||
{ value: "arabic", label: "Arabic", isVip: false, code:'AR' },
|
||
{ value: "russian", label: "Russian", isVip: false, code:'RU' },
|
||
{ value: "thai", label: "Thai", isVip: false, code:'TH' },
|
||
{ value: "french", label: "French", isVip: false, code:'FR' },
|
||
{ value: "german", label: "German", isVip: false, code:'DE' },
|
||
{ value: "vietnamese", label: "Vietnamese", isVip: false, code:'VI' },
|
||
{ value: "indonesian", label: "Indonesian", isVip: false, code:'ID' }
|
||
]
|
||
|
||
const VideoDurationOptions = [
|
||
{ value: "8s", label: "8s" },
|
||
{ value: "1min", label: "1min" },
|
||
{ value: "2min", label: "2min" },
|
||
{ value: "unlimited", label: "unlimited" },
|
||
];
|
||
|
||
/**模板故事模式弹窗组件 */
|
||
/**
|
||
* 防抖函数
|
||
* @param {Function} func - 需要防抖的函数
|
||
* @param {number} wait - 等待时间(ms)
|
||
* @returns {Function} - 防抖后的函数
|
||
*/
|
||
const debounce = (func: Function, wait: number) => {
|
||
let timeout: NodeJS.Timeout;
|
||
return function executedFunction(...args: any[]) {
|
||
const later = () => {
|
||
clearTimeout(timeout);
|
||
func(...args);
|
||
};
|
||
clearTimeout(timeout);
|
||
timeout = setTimeout(later, wait);
|
||
};
|
||
};
|
||
|
||
/**
|
||
* 视频工具面板组件
|
||
* 提供脚本输入和视频克隆两种模式,支持展开/收起功能
|
||
*/
|
||
export function ChatInputBox({ noData }: { noData: boolean }) {
|
||
const { isMobile, isDesktop } = useDeviceType();
|
||
// 控制面板展开/收起状态
|
||
const [isExpanded, setIsExpanded] = useState(false);
|
||
|
||
// 模板故事弹窗状态
|
||
const [isTemplateModalOpen, setIsTemplateModalOpen] = useState(false);
|
||
|
||
// 图片故事弹窗状态
|
||
const [isPhotoStoryModalOpen, setIsPhotoStoryModalOpen] = useState(false);
|
||
|
||
// 共享状态 - 需要在不同渲染函数间共享
|
||
const [script, setScript] = useState(""); // 用户输入的脚本内容
|
||
const router = useRouter();
|
||
|
||
const [loadingIdea, setLoadingIdea] = useState(false); // 获取创意建议时的加载状态
|
||
// 各种操作的加载状态
|
||
const [isCreating, setIsCreating] = useState(false); // 主视频创建按钮的加载状态
|
||
const [isTemplateCreating, setIsTemplateCreating] = useState(false); // 模板故事创建按钮的加载状态
|
||
const [isPhotoCreating, setIsPhotoCreating] = useState(false); // 图片故事创建按钮的加载状态
|
||
const [isRoleGenerating, setIsRoleGenerating] = useState<{[key: string]: boolean}>({}); // 角色AI生成按钮的加载状态
|
||
const [isItemGenerating, setIsItemGenerating] = useState<{[key: string]: boolean}>({}); // 道具AI生成按钮的加载状态
|
||
|
||
// 配置选项状态 - 整合所有配置项到一个对象
|
||
type ConfigOptions = {
|
||
mode: "auto" | "manual";
|
||
resolution: "720p" | "1080p" | "4k";
|
||
language: string;
|
||
videoDuration: string;
|
||
expansion_mode: boolean;
|
||
};
|
||
|
||
const [configOptions, setConfigOptions] = useState<ConfigOptions>({
|
||
mode: "auto",
|
||
resolution: "720p",
|
||
language: "english",
|
||
videoDuration: "unlimited",
|
||
expansion_mode: true,
|
||
});
|
||
|
||
// 从 localStorage 初始化配置
|
||
useEffect(() => {
|
||
const savedConfig = localStorage.getItem('videoFlowConfig');
|
||
if (savedConfig) {
|
||
try {
|
||
const parsed = JSON.parse(savedConfig);
|
||
setConfigOptions({
|
||
mode: parsed.mode || "auto",
|
||
resolution: parsed.resolution || "720p",
|
||
language: parsed.language || "english",
|
||
videoDuration: parsed.videoDuration || "1min",
|
||
expansion_mode: typeof parsed.expansion_mode === 'boolean' ? parsed.expansion_mode : false,
|
||
});
|
||
} catch (error) {
|
||
console.warn('解析保存的配置失败,使用默认配置:', error);
|
||
}
|
||
}
|
||
}, []);
|
||
|
||
const onConfigChange = <K extends keyof ConfigOptions>(key: K, value: ConfigOptions[K]) => {
|
||
setConfigOptions((prev) => ({
|
||
...prev,
|
||
[key]: value,
|
||
}));
|
||
if (key === 'videoDuration') {
|
||
// 当选择 8s 时,强制关闭剧本扩展并禁用开关
|
||
if (value === '8s') {
|
||
setConfigOptions((prev) => ({
|
||
...prev,
|
||
expansion_mode: false,
|
||
}));
|
||
} else {
|
||
setConfigOptions((prev) => ({
|
||
...prev,
|
||
expansion_mode: true,
|
||
}));
|
||
}
|
||
}
|
||
};
|
||
|
||
const handleCreateVideo = async () => {
|
||
if (isCreating) return; // 如果正在创建中,直接返回
|
||
|
||
if (!script) {
|
||
console.warn("请输入剧本内容");
|
||
return;
|
||
}
|
||
|
||
setIsCreating(true); // 设置创建状态为 true,按钮会显示 loading 状态
|
||
|
||
try {
|
||
const User = JSON.parse(localStorage.getItem("currentUser") || "{}");
|
||
|
||
if (!User.id) {
|
||
console.error("用户未登录");
|
||
return;
|
||
}
|
||
|
||
// 创建剧集数据
|
||
let episodeData: any = {
|
||
user_id: String(User.id),
|
||
script: script,
|
||
mode: configOptions.mode,
|
||
resolution: configOptions.resolution,
|
||
language: configOptions.language,
|
||
video_duration: configOptions.videoDuration,
|
||
expansion_mode: configOptions.expansion_mode,
|
||
};
|
||
|
||
// 调用创建剧集API
|
||
const result = await MovieProjectService.createProject(
|
||
MovieProjectMode.NORMAL,
|
||
episodeData
|
||
);
|
||
|
||
const episodeId = result.project_id;
|
||
router.push(`/movies/work-flow?episodeId=${episodeId}`);
|
||
|
||
} catch (error) {
|
||
console.error("创建剧集失败:", error);
|
||
} finally {
|
||
setIsCreating(false); // 无论成功还是失败,都重置创建状态
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div
|
||
className="video-tool-component relative max-w-[1080px] w-full left-[50%] translate-x-[-50%]"
|
||
style={
|
||
noData
|
||
? {
|
||
top: "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)]">
|
||
{/* 展开/收起控制区域 */}
|
||
{!noData && (
|
||
<>
|
||
{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">
|
||
Click to act
|
||
</span>
|
||
</div>
|
||
) : (
|
||
// 收起状态:显示展开按钮
|
||
<div
|
||
className="absolute top-[-8px] left-[50%] z-[2] flex items-center justify-center w-[46px] h-4 rounded-[15px] bg-[#fff3] translate-x-[-50%] cursor-pointer hover:bg-[#ffffff1a]"
|
||
onClick={() => setIsExpanded(true)}
|
||
>
|
||
<ChevronDown className="w-4 h-4" />
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* 主要内容区域 - 简化层级,垂直居中 */}
|
||
<div
|
||
data-alt="+ ---------"
|
||
className={`flex items-center justify-center p-1 transition-all duration-300 relative ${
|
||
isExpanded ? "h-[2rem]" : "h-auto"
|
||
}`}
|
||
>
|
||
|
||
{/* 输入框和Action按钮 - 只在展开状态显示 */}
|
||
{!isExpanded && (
|
||
<div className="flex flex-col gap-3 w-full">
|
||
{/* 第一行:输入框 */}
|
||
<div className="video-prompt-editor relative flex flex-col gap-3 flex-1 pr-10">
|
||
{/* 文本输入框 - 改为textarea */}
|
||
<textarea
|
||
value={script}
|
||
onChange={(e) => setScript(e.target.value)}
|
||
placeholder="Describe the story you want to make..."
|
||
className="w-full pl-[10px] pr-[10px] py-[14px] rounded-[10px] leading-[20px] text-sm border-none bg-transparent text-white placeholder:text-white/[0.40] focus:outline-none resize-none min-h-[48px] max-h-[120px] overflow-y-auto"
|
||
style={
|
||
noData
|
||
? {
|
||
minHeight: "128px",
|
||
}
|
||
: {}
|
||
}
|
||
rows={1}
|
||
onInput={(e) => {
|
||
const target = e.target as HTMLTextAreaElement;
|
||
target.style.height = "auto";
|
||
target.style.height =
|
||
Math.min(target.scrollHeight, 120) + "px";
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
{/* 第二行:功能按钮和Action按钮 - 同一行 */}
|
||
<div className="flex items-center justify-between">
|
||
{/* 左侧功能按钮区域 */}
|
||
<div className="flex items-center gap-1">
|
||
{/* 获取创意按钮
|
||
<Tooltip
|
||
title="Get creative ideas for your story"
|
||
placement="top"
|
||
>
|
||
<button
|
||
data-alt="get-idea-button"
|
||
className="flex items-center gap-1.5 px-3 py-2 text-white/[0.70] hover:text-white transition-colors"
|
||
onClick={() => handleGetIdea()}
|
||
>
|
||
{loadingIdea ? (
|
||
<Loader2 className="w-4 h-4 animate-spin" />
|
||
) : (
|
||
<Lightbulb className="w-4 h-4" />
|
||
)}
|
||
</button>
|
||
</Tooltip> */}
|
||
|
||
{/* 分隔线 */}
|
||
{/* <div className="w-px h-4 bg-white/[0.20]"></div> */}
|
||
|
||
{/* 模板故事按钮 */}
|
||
<Tooltip title="Choose from movie templates" placement="top" trigger={isDesktop ? "hover" : "contextMenu"}>
|
||
<button
|
||
data-alt="template-story-button"
|
||
className="flex items-center gap-1.5 px-2 py-2 text-white/[0.70] hover:text-white transition-colors"
|
||
onClick={() => setIsTemplateModalOpen(true)}
|
||
>
|
||
<LayoutTemplate className="w-4 h-4" />
|
||
</button>
|
||
</Tooltip>
|
||
|
||
{/* 分隔线 */}
|
||
<div className="w-px h-4 bg-white/[0.20]"></div>
|
||
|
||
{/* 图片故事按钮 */}
|
||
<Tooltip title="Create movie from image" placement="top" trigger={isDesktop ? "hover" : "contextMenu"}>
|
||
<button
|
||
data-alt="photo-story-button"
|
||
className="flex items-center gap-1.5 px-2 py-2 text-white/[0.70] hover:text-white transition-colors"
|
||
onClick={() => setIsPhotoStoryModalOpen(true)}
|
||
>
|
||
<ImagePlay className="w-4 h-4" />
|
||
</button>
|
||
</Tooltip>
|
||
|
||
{isDesktop ? (
|
||
<PcPhotoStoryModal
|
||
isOpen={isPhotoStoryModalOpen}
|
||
onClose={() => setIsPhotoStoryModalOpen(false)}
|
||
configOptions={configOptions}
|
||
isCreating={isCreating}
|
||
setIsCreating={setIsCreating}
|
||
isPhotoCreating={isPhotoCreating}
|
||
setIsPhotoCreating={setIsPhotoCreating}
|
||
/>
|
||
) : (
|
||
<H5PhotoStoryDrawer
|
||
isMobile={isMobile}
|
||
isOpen={isPhotoStoryModalOpen}
|
||
onClose={() => setIsPhotoStoryModalOpen(false)}
|
||
configOptions={configOptions}
|
||
isCreating={isCreating}
|
||
setIsCreating={setIsCreating}
|
||
isPhotoCreating={isPhotoCreating}
|
||
setIsPhotoCreating={setIsPhotoCreating}
|
||
/>
|
||
)}
|
||
|
||
{/* 分隔线 */}
|
||
<div className="w-px h-4 bg-white/[0.20]"></div>
|
||
|
||
{/* 语言配置 */}
|
||
<Dropdown
|
||
overlayClassName="language-dropdown"
|
||
menu={{
|
||
items: LauguageOptions.map((option) => ({
|
||
key: option.value,
|
||
label: (
|
||
<div className={`flex items-center justify-between px-2 py-2 ${
|
||
option.value === configOptions.language
|
||
? "bg-white/[0.12] rounded-md"
|
||
: ""
|
||
}`}>
|
||
<span className="text-sm text-white">{option.label}</span>
|
||
{option.isVip && (
|
||
<Crown className="w-4 h-4 text-yellow-500" />
|
||
)}
|
||
</div>
|
||
),
|
||
})),
|
||
onClick: ({ key }) => onConfigChange('language', key),
|
||
}}
|
||
trigger={["click"]}
|
||
placement="top"
|
||
>
|
||
<button
|
||
data-alt={`config-language`}
|
||
className={`flex items-center gap-1 text-white/80 transition-all duration-200 px-2 py-2`}
|
||
>
|
||
<Globe className={"w-4 h-4"} />
|
||
<span className="text-sm">{LauguageOptions.find((option) => option.value === configOptions.language)?.code}</span>
|
||
</button>
|
||
</Dropdown>
|
||
|
||
{/* 分隔线 */}
|
||
<div className="w-px h-4 bg-white/[0.20]"></div>
|
||
|
||
{/* 剧本扩展开关 */}
|
||
<Tooltip title="Enable script expansion" placement="top" trigger={isDesktop ? "hover" : "click"}>
|
||
<div data-alt="config-expansion-mode" className="flex items-center gap-1 px-1">
|
||
{/* 仅在未选中时显示背景色,避免覆盖选中态 */}
|
||
<div className={`${configOptions.expansion_mode ? 'bg-transparent' : 'bg-white/40'} rounded-full transition-colors flex`}>
|
||
<Switch
|
||
size="small"
|
||
checked={configOptions.expansion_mode}
|
||
disabled={configOptions.videoDuration === '8s'}
|
||
onChange={(checked) => onConfigChange('expansion_mode', checked)}
|
||
/>
|
||
</div>
|
||
<span className={`text-xs text-white`}>
|
||
{configOptions.expansion_mode ? 'On' : 'Off'}
|
||
</span>
|
||
</div>
|
||
</Tooltip>
|
||
|
||
{/* 分隔线 */}
|
||
<div className="w-px h-4 bg-white/[0.20]"></div>
|
||
|
||
{/* 时长选择 */}
|
||
<Dropdown
|
||
overlayClassName="duration-dropdown"
|
||
menu={{
|
||
items: VideoDurationOptions.map((option) => ({
|
||
key: option.value,
|
||
label: (
|
||
<div className={`flex items-center justify-between px-2 py-2 ${
|
||
option.value === configOptions.videoDuration
|
||
? "bg-white/[0.12] rounded-md"
|
||
: ""
|
||
}`}>
|
||
<span className="text-sm text-white">{option.label}</span>
|
||
</div>
|
||
),
|
||
})),
|
||
onClick: ({ key }) => onConfigChange('videoDuration', key as string),
|
||
}}
|
||
trigger={["click"]}
|
||
placement="top"
|
||
>
|
||
<button
|
||
data-alt={`config-video-duration`}
|
||
className={`flex items-center gap-1 text-white/80 transition-all duration-200 px-2 py-2`}
|
||
>
|
||
<Clock className={"w-4 h-4"} />
|
||
<span className="text-sm">{configOptions.videoDuration}</span>
|
||
</button>
|
||
</Dropdown>
|
||
</div>
|
||
|
||
{/* 右侧Action按钮 */}
|
||
<ActionButton
|
||
isCreating={isCreating}
|
||
handleCreateVideo={handleCreateVideo}
|
||
icon={<Clapperboard className={`${isMobile ? "w-4 h-4" : "w-5 h-5"}`} />}
|
||
className="mr-1 mb-1"
|
||
width={isMobile ? "w-10" : "w-12"}
|
||
height={isMobile ? "h-10" : "h-12"}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 模板故事弹窗 */}
|
||
{isDesktop ? (
|
||
<PcTemplateModal
|
||
configOptions={configOptions}
|
||
isOpen={isTemplateModalOpen}
|
||
onClose={() => setIsTemplateModalOpen(false)}
|
||
isTemplateCreating={isTemplateCreating}
|
||
setIsTemplateCreating={setIsTemplateCreating}
|
||
isRoleGenerating={isRoleGenerating}
|
||
setIsRoleGenerating={setIsRoleGenerating}
|
||
isItemGenerating={isItemGenerating}
|
||
setIsItemGenerating={setIsItemGenerating}
|
||
/>
|
||
) : (
|
||
<H5TemplateDrawer
|
||
isMobile={isMobile}
|
||
configOptions={configOptions}
|
||
isOpen={isTemplateModalOpen}
|
||
onClose={() => setIsTemplateModalOpen(false)}
|
||
isTemplateCreating={isTemplateCreating}
|
||
setIsTemplateCreating={setIsTemplateCreating}
|
||
isRoleGenerating={isRoleGenerating}
|
||
setIsRoleGenerating={setIsRoleGenerating}
|
||
isItemGenerating={isItemGenerating}
|
||
setIsItemGenerating={setIsItemGenerating}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|