forked from 77media/video-flow
添加音频播放器组件,更新视频工具界面,迁移视频上传和创建功能至ChatInputBox组件,优化样式和状态管理。
This commit is contained in:
parent
2c4bb10a36
commit
cc973ba4ac
96
README.md
96
README.md
@ -1,10 +1,99 @@
|
||||
安装依赖
|
||||
# Video Flow
|
||||
|
||||
一个基于Next.js的视频创作工具,支持多种创作模式和故事模板。
|
||||
|
||||
```shellscript
|
||||
npm install
|
||||
## 故事模板组件
|
||||
|
||||
### 功能特性
|
||||
|
||||
故事模板交互组件 (`renderTemplateStoryMode`) 提供了以下功能:
|
||||
|
||||
#### 1. 模板列表显示
|
||||
- 从 `StoryTemplateEntity` API 请求模板数据
|
||||
- 以横向滚动图标列表形式展示多个模板选项
|
||||
- 每个模板显示预览图片和名称
|
||||
- 支持加载状态和错误处理
|
||||
|
||||
#### 2. 模板详情弹窗
|
||||
- 用户点击模板图标后弹出模态框
|
||||
- 弹窗宽度为80%,居中显示
|
||||
- 顶部布局:
|
||||
- 左侧:大图片预览(40%宽度)
|
||||
- 右侧:故事模板名称和提示词描述
|
||||
- 图片支持鼠标悬停动画效果(轻微缩放和旋转)
|
||||
|
||||
#### 3. 角色自定义功能
|
||||
- 显示可演绎的角色列表(基于模板数据)
|
||||
- 每个角色支持:
|
||||
- 上传图片替换默认角色图像
|
||||
- 录制音频功能
|
||||
- 上传音频文件功能
|
||||
- 交互式按钮设计,支持悬停效果
|
||||
|
||||
#### 4. 确认操作
|
||||
- 弹窗底部提供"取消"和"确定"按钮
|
||||
- 确定按钮执行空函数(可后续替换为实际API调用)
|
||||
- 支持点击遮罩层关闭弹窗
|
||||
|
||||
### 技术实现
|
||||
|
||||
#### 状态管理
|
||||
```typescript
|
||||
const [templates, setTemplates] = useState<StoryTemplateEntity[]>([]);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<StoryTemplateEntity | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
```
|
||||
|
||||
#### 核心功能
|
||||
- **模板数据获取**: 模拟API调用,支持异步加载
|
||||
- **模板选择**: 点击模板图标打开详情弹窗
|
||||
- **资源上传**: 支持图片和音频文件上传
|
||||
- **音频录制**: 预留音频录制接口
|
||||
- **响应式设计**: 使用Tailwind CSS实现现代UI
|
||||
|
||||
#### 样式特点
|
||||
- 遵循现有组件设计风格
|
||||
- 使用毛玻璃效果和渐变背景
|
||||
- 支持悬停动画和过渡效果
|
||||
- 响应式布局,适配不同屏幕尺寸
|
||||
|
||||
### 使用方法
|
||||
|
||||
1. 在 `ChatInputBox` 组件中切换到 "template" 标签页
|
||||
2. 浏览横向滚动的模板列表
|
||||
3. 点击感兴趣的模板图标
|
||||
4. 在弹窗中查看模板详情和自定义角色
|
||||
5. 上传角色图片和音频资源
|
||||
6. 点击确定完成模板选择
|
||||
|
||||
### 数据结构
|
||||
|
||||
组件使用 `StoryTemplateEntity` 接口定义模板数据结构:
|
||||
|
||||
```typescript
|
||||
interface StoryTemplateEntity {
|
||||
readonly id: string;
|
||||
name: string;
|
||||
imageUrl: string;
|
||||
generateText: string;
|
||||
storyRole: string[];
|
||||
userResources: {
|
||||
role_name: string;
|
||||
photo_url: string;
|
||||
voice_url: string;
|
||||
}[];
|
||||
}
|
||||
```
|
||||
|
||||
### 扩展建议
|
||||
|
||||
- 集成真实的API接口替换模拟数据
|
||||
- 添加音频预览播放器功能
|
||||
- 实现上传进度条和状态提示
|
||||
- 支持模板收藏和最近使用功能
|
||||
- 添加模板搜索和分类筛选
|
||||
|
||||
## 启动开发服务器
|
||||
|
||||
运行以下命令启动开发服务器:
|
||||
@ -29,4 +118,3 @@ ssh 77media@182.92.218.171
|
||||
11) Deploy Vedio Flow Frontend
|
||||
Please select a service to deploy (0-10): 11
|
||||
```
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import './globals.css';
|
||||
import type { Metadata } from 'next';
|
||||
import { Providers } from '@/components/providers';
|
||||
import { ConfigProvider, theme } from 'antd';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'AI Movie Flow - Create Amazing Videos with AI',
|
||||
@ -15,10 +16,23 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className="font-sans antialiased">
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
algorithm: theme.darkAlgorithm,
|
||||
token: {
|
||||
// 自定义暗色主题颜色
|
||||
colorBgContainer: '#1B1B1B',
|
||||
colorBgElevated: '#1B1B1B',
|
||||
colorBgMask: 'rgba(0, 0, 0, 0.6)',
|
||||
borderRadius: 16,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Providers>
|
||||
{children}
|
||||
</Providers>
|
||||
</ConfigProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,6 +18,13 @@ export enum ResolutionEnum {
|
||||
UHD_4K = '4k', // "4k"
|
||||
}
|
||||
|
||||
// 视频时长枚举
|
||||
export enum VideoDurationEnum {
|
||||
ONE_MINUTE = '1min', // "一分钟"
|
||||
TWO_MINUTES = '2min', // "两分钟"
|
||||
THREE_MINUTES = '3min', // "三分钟"
|
||||
}
|
||||
|
||||
// 工作流阶段枚举
|
||||
export enum FlowStageEnum {
|
||||
PREVIEW = 1, // "预览图列表分镜草图"
|
||||
@ -45,7 +52,7 @@ export const ProjectTypeMap = {
|
||||
tab: "script"
|
||||
},
|
||||
[ProjectTypeEnum.VIDEO_TO_VIDEO]: {
|
||||
value: "video_to_video",
|
||||
value: "video_to_video",
|
||||
label: "clone",
|
||||
tab: "clone"
|
||||
}
|
||||
@ -83,6 +90,22 @@ export const ResolutionMap = {
|
||||
}
|
||||
} as const;
|
||||
|
||||
// 视频时长映射
|
||||
export const VideoDurationMap = {
|
||||
[VideoDurationEnum.ONE_MINUTE]: {
|
||||
value: "1min",
|
||||
label: "One Minute"
|
||||
},
|
||||
[VideoDurationEnum.TWO_MINUTES]: {
|
||||
value: "2min",
|
||||
label: "Two Minutes"
|
||||
},
|
||||
[VideoDurationEnum.THREE_MINUTES]: {
|
||||
value: "3min",
|
||||
label: "Three Minutes"
|
||||
}
|
||||
} as const;
|
||||
|
||||
// 工作流阶段映射
|
||||
export const FlowStageMap = {
|
||||
[FlowStageEnum.PREVIEW]: {
|
||||
@ -133,7 +156,7 @@ export const TaskStatusMap = {
|
||||
value: "failed",
|
||||
label: "失败"
|
||||
}
|
||||
} as const;
|
||||
} as const;
|
||||
|
||||
// 分镜脚本编辑器类型定义
|
||||
export interface StoryboardCard {
|
||||
@ -223,4 +246,4 @@ export const characterInfoMap: Record<string, CharacterInfo> = {
|
||||
|
||||
export const sceneInfoMap: Record<string, SceneInfo> = {
|
||||
'暮色森林': { image: 'https://c.huiying.video/images/7fd3f2d6-840a-46ac-a97d-d3d1b37ec4e0.jpg', location: '西境边陲', time: '傍晚' },
|
||||
};
|
||||
};
|
||||
|
||||
@ -81,19 +81,6 @@ export interface VideoSegmentEntity {
|
||||
lens: LensType[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片故事类型枚举
|
||||
* @description 标识图片故事的来源类型
|
||||
*/
|
||||
export enum ImageStoryType {
|
||||
/** 空文案自动生成故事 */
|
||||
autoStory = "autoStory",
|
||||
/** 用户描述生成故事 */
|
||||
userStory = "userStory",
|
||||
/** 模板故事 */
|
||||
templateStory = "templateStory",
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片故事实体接口
|
||||
* @description 表示一条图片故事及其相关信息
|
||||
@ -103,29 +90,37 @@ export interface ImageStoryEntity {
|
||||
readonly id: string;
|
||||
/** 图片URL */
|
||||
imageUrl: string;
|
||||
/** 图片描述 */
|
||||
imageDescription: string;
|
||||
/** 图片故事内容 */
|
||||
imageStory: string;
|
||||
/** 图片故事剧本 */
|
||||
imageScript: string;
|
||||
/** 故事涉及的角色 */
|
||||
storyRole: RoleEntity[];
|
||||
/** 故事类型 */
|
||||
storyType: ImageStoryType;
|
||||
/** 故事分类 */
|
||||
storyType: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 故事模板实体接口
|
||||
* @description 表示一个故事模板及其相关信息
|
||||
*/
|
||||
export interface StoryTemplateEntity {
|
||||
/** 唯一标识 */
|
||||
readonly id: string;
|
||||
/** 故事模板名称 */
|
||||
name: string;
|
||||
/** 故事模板描述 */
|
||||
description: string;
|
||||
/** 故事模板图片 */
|
||||
imageUrl: string;
|
||||
/** 故事模板提示词 */
|
||||
generateText: string;
|
||||
/**故事角色 */
|
||||
storyRole: RoleEntity[];
|
||||
storyRole: string[];
|
||||
/**用户自定义演绎资源 */
|
||||
userResources: {
|
||||
/**对应角色名 */
|
||||
role_name: string;
|
||||
/**照片URL */
|
||||
photo_url: string;
|
||||
/**声音URL */
|
||||
voice_url: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
1527
components/common/ChatInputBox.tsx
Normal file
1527
components/common/ChatInputBox.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { ArrowLeft, ChevronDown, ChevronUp, Video, ListOrdered, Play, Loader2, Pause, MoreHorizontal, Edit2, Check, X, RefreshCw, Lightbulb, Package, Crown, ArrowUp, Search, Filter, Grid, Grid3X3, Calendar, Clock, Eye, Heart, Share2, Globe } from 'lucide-react';
|
||||
import { ArrowLeft, ListOrdered, Play, Loader2, Pause, MoreHorizontal, Edit2, Check, X, RefreshCw, Calendar, Clock, Eye, Heart, Share2, Video } from 'lucide-react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Input } from "@/components/ui/input";
|
||||
import './style/create-to-video2.css';
|
||||
@ -12,29 +12,19 @@ import { ModeEnum, ResolutionEnum } from "@/app/model/enums";
|
||||
import { createScriptEpisodeNew, getScriptEpisodeListNew, CreateScriptEpisodeRequest } from "@/api/script_episode";
|
||||
import { getUploadToken, uploadToQiniu } from "@/api/common";
|
||||
import { EmptyStateAnimation } from '@/components/common/EmptyStateAnimation2';
|
||||
import { ChatInputBox } from '@/components/common/ChatInputBox';
|
||||
|
||||
|
||||
const ideaText = 'a cute capybara with an orange on its head, staring into the distance and walking forward';
|
||||
// ideaText已迁移到ChatInputBox组件中
|
||||
|
||||
export function CreateToVideo2() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const projectId = searchParams.get('projectId') ? parseInt(searchParams.get('projectId')!) : 0;
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [videoUrl, setVideoUrl] = useState('');
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [activeTab, setActiveTab] = useState('script');
|
||||
const [isFocus, setIsFocus] = useState(false);
|
||||
const [selectedMode, setSelectedMode] = useState<ModeEnum>(ModeEnum.AUTOMATIC);
|
||||
const [selectedResolution, setSelectedResolution] = useState<ResolutionEnum>(ResolutionEnum.HD_720P);
|
||||
const [selectedLanguage, setSelectedLanguage] = useState<string>('english');
|
||||
const [script, setInputText] = useState('');
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const [runTour, setRunTour] = useState(true);
|
||||
const [episodeId, setEpisodeId] = useState<number>(0);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [generatedVideoList, setGeneratedVideoList] = useState<any[]>([]);
|
||||
const [projectName, setProjectName] = useState('默认名称');
|
||||
const [episodeList, setEpisodeList] = useState<any[]>([]);
|
||||
@ -46,8 +36,6 @@ export function CreateToVideo2() {
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [userId, setUserId] = useState<number>(0);
|
||||
const [isComposing, setIsComposing] = useState(false);
|
||||
const [loadingIdea, setLoadingIdea] = useState(false);
|
||||
|
||||
// 在客户端挂载后读取localStorage
|
||||
useEffect(() => {
|
||||
@ -97,331 +85,9 @@ export function CreateToVideo2() {
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
// 视频上传和创建功能已迁移到ChatInputBox组件中
|
||||
|
||||
// 获取上传token
|
||||
const { token } = await getUploadToken();
|
||||
|
||||
// 上传到七牛云
|
||||
const videoUrl = await uploadToQiniu(file, token);
|
||||
|
||||
// 上传成功,设置视频URL
|
||||
setVideoUrl(videoUrl);
|
||||
console.log('视频上传成功:', videoUrl);
|
||||
} catch (error) {
|
||||
console.error('上传错误:', error);
|
||||
alert('上传失败,请稍后重试');
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
input.click();
|
||||
}
|
||||
|
||||
const handleCreateVideo = async () => {
|
||||
setIsCreating(true);
|
||||
// 创建剧集数据
|
||||
let episodeData: any = {
|
||||
user_id: String(userId),
|
||||
script: script,
|
||||
mode: selectedMode,
|
||||
resolution: selectedResolution,
|
||||
language: selectedLanguage
|
||||
};
|
||||
|
||||
// 调用创建剧集API
|
||||
const episodeResponse = await createScriptEpisodeNew(episodeData);
|
||||
console.log('episodeResponse', episodeResponse);
|
||||
if (episodeResponse.code !== 0) {
|
||||
console.error(`创建剧集失败: ${episodeResponse.message}`);
|
||||
alert(`创建剧集失败: ${episodeResponse.message}`);
|
||||
return;
|
||||
}
|
||||
let episodeId = episodeResponse.data.project_id;
|
||||
// let episodeId = '9c34fcc4-c8d8-44fc-879e-9bd56f608c76';
|
||||
router.push(`/create/work-flow?episodeId=${episodeId}`);
|
||||
setIsCreating(false);
|
||||
}
|
||||
|
||||
// 下拉菜单项配置
|
||||
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 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 handleGetIdea = () => {
|
||||
if (loadingIdea) return;
|
||||
setLoadingIdea(true);
|
||||
setTimeout(() => {
|
||||
setInputText(ideaText);
|
||||
setLoadingIdea(false);
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
// 处理编辑器聚焦
|
||||
const handleEditorFocus = () => {
|
||||
setIsFocus(true);
|
||||
if (editorRef.current) {
|
||||
const range = document.createRange();
|
||||
const selection = window.getSelection();
|
||||
const textNode = Array.from(editorRef.current.childNodes).find(
|
||||
node => node.nodeType === Node.TEXT_NODE
|
||||
);
|
||||
|
||||
if (!textNode) {
|
||||
const newTextNode = document.createTextNode(script || '');
|
||||
editorRef.current.appendChild(newTextNode);
|
||||
range.setStart(newTextNode, (script || '').length);
|
||||
range.setEnd(newTextNode, (script || '').length);
|
||||
} else {
|
||||
range.setStart(textNode, textNode.textContent?.length || 0);
|
||||
range.setEnd(textNode, textNode.textContent?.length || 0);
|
||||
}
|
||||
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditorChange = (e: React.FormEvent<HTMLDivElement>) => {
|
||||
const newText = e.currentTarget.textContent || '';
|
||||
|
||||
// 如果正在输入中文,只更新内部文本,不更新状态
|
||||
if (isComposing) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新状态
|
||||
setInputText(newText);
|
||||
|
||||
// 保存当前选区位置
|
||||
const selection = window.getSelection();
|
||||
if (selection && selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
const currentPosition = range.startOffset;
|
||||
|
||||
// 使用 requestAnimationFrame 确保在下一帧恢复光标位置
|
||||
requestAnimationFrame(() => {
|
||||
if (editorRef.current) {
|
||||
// 找到或创建文本节点
|
||||
let textNode = Array.from(editorRef.current.childNodes).find(
|
||||
node => node.nodeType === Node.TEXT_NODE
|
||||
) as Text;
|
||||
|
||||
if (!textNode) {
|
||||
textNode = document.createTextNode(newText);
|
||||
editorRef.current.appendChild(textNode);
|
||||
}
|
||||
|
||||
// 计算正确的光标位置
|
||||
const finalPosition = Math.min(currentPosition, textNode.length);
|
||||
|
||||
// 设置新的选区
|
||||
const newRange = document.createRange();
|
||||
newRange.setStart(textNode, finalPosition);
|
||||
newRange.setEnd(textNode, finalPosition);
|
||||
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(newRange);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 处理中文输入开始
|
||||
const handleCompositionStart = () => {
|
||||
setIsComposing(true);
|
||||
};
|
||||
|
||||
// 处理中文输入结束
|
||||
const handleCompositionEnd = (e: React.CompositionEvent<HTMLDivElement>) => {
|
||||
setIsComposing(false);
|
||||
|
||||
// 在输入完成后更新内容
|
||||
const newText = e.currentTarget.textContent || '';
|
||||
setInputText(newText);
|
||||
|
||||
// 保存并恢复光标位置
|
||||
const selection = window.getSelection();
|
||||
if (selection && selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
const currentPosition = range.startOffset;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (editorRef.current) {
|
||||
let textNode = Array.from(editorRef.current.childNodes).find(
|
||||
node => node.nodeType === Node.TEXT_NODE
|
||||
) as Text;
|
||||
|
||||
if (!textNode) {
|
||||
textNode = document.createTextNode(newText);
|
||||
editorRef.current.appendChild(textNode);
|
||||
}
|
||||
|
||||
// 计算正确的光标位置
|
||||
const finalPosition = Math.min(currentPosition, textNode.length);
|
||||
|
||||
// 设置新的选区
|
||||
const newRange = document.createRange();
|
||||
newRange.setStart(textNode, finalPosition);
|
||||
newRange.setEnd(textNode, finalPosition);
|
||||
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(newRange);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
// 所有视频工具相关的函数和配置已迁移到ChatInputBox组件中
|
||||
|
||||
// 检查是否需要显示引导
|
||||
useEffect(() => {
|
||||
@ -598,181 +264,8 @@ export function CreateToVideo2() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 创建工具栏 */}
|
||||
<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]'>
|
||||
<div className={`tab-item ${activeTab === 'script' ? 'active' : ''} cursor-pointer`} onClick={() => setActiveTab('script')}>
|
||||
<span className='text-lg opacity-60'>script</span>
|
||||
</div>
|
||||
<div className={`tab-item ${activeTab === 'clone' ? 'active' : ''} cursor-pointer`} onClick={() => setActiveTab('clone')}>
|
||||
<span className='text-lg opacity-60'>clone</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'>
|
||||
{activeTab === 'clone' && (
|
||||
<div className='relative flex items-center gap-4 h-[94px]'>
|
||||
<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>
|
||||
{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>
|
||||
)}
|
||||
{activeTab === 'script' && (
|
||||
<div className='relative flex items-center gap-4 h-[94px]'>
|
||||
<div className={`video-prompt-editor relative flex flex-1 self-stretch items-center w-0 rounded-[6px] ${isFocus ? 'focus' : ''}`}>
|
||||
<div
|
||||
ref={editorRef}
|
||||
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={handleEditorFocus}
|
||||
onBlur={() => setIsFocus(false)}
|
||||
onInput={handleEditorChange}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
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>
|
||||
)}
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className={`tool-submit-button ${videoUrl || script ? '' : '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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 视频工具组件 - 使用独立组件 */}
|
||||
<ChatInputBox />
|
||||
|
||||
{episodeList.length === 0 && !isLoading && (
|
||||
<div className='h-full flex flex-col items-center fixed top-[4rem] left-0 right-0 bottom-0'>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
.video-tool-component {
|
||||
position: fixed;
|
||||
bottom: 1rem;
|
||||
bottom: 1.5rem;
|
||||
z-index: 9;
|
||||
}
|
||||
|
||||
@ -8,7 +8,10 @@
|
||||
border: 1px solid rgba(255, 255, 255, .2);
|
||||
box-shadow: 0 4px 20px #0009;
|
||||
}
|
||||
|
||||
.video-storyboard-tools-clean{
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
.video-storyboard-tools .tool-submit-button {
|
||||
display: flex;
|
||||
height: 36px;
|
||||
@ -144,4 +147,4 @@
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
perspective: 500px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
.video-tool-component {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 1rem;
|
||||
bottom: 1.5rem;
|
||||
z-index: 99;
|
||||
--tw-translate-x: calc(-50% + 34.5px);
|
||||
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
|
||||
@ -26,4 +26,4 @@
|
||||
background-color: #fff;
|
||||
opacity: .3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
36
package-lock.json
generated
36
package-lock.json
generated
@ -88,6 +88,7 @@
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "18.2.0",
|
||||
"react-grid-layout": "^1.5.1",
|
||||
"react-h5-audio-player": "^3.10.0",
|
||||
"react-hook-form": "^7.53.0",
|
||||
"react-intersection-observer": "^9.16.0",
|
||||
"react-joyride": "^2.9.3",
|
||||
@ -1019,6 +1020,27 @@
|
||||
"integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@iconify/react": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@iconify/react/-/react-5.2.1.tgz",
|
||||
"integrity": "sha512-37GDR3fYDZmnmUn9RagyaX+zca24jfVOMY8E1IXTqJuE8pxNtN51KWPQe3VODOWvuUurq7q9uUu3CFrpqj5Iqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@iconify/types": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/cyberalien"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/@iconify/types": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
|
||||
"integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@isaacs/cliui": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||
@ -17807,6 +17829,20 @@
|
||||
"react-dom": ">= 16.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-h5-audio-player": {
|
||||
"version": "3.10.0",
|
||||
"resolved": "https://registry.npmjs.org/react-h5-audio-player/-/react-h5-audio-player-3.10.0.tgz",
|
||||
"integrity": "sha512-y1PRCwGy8TfpTQaoV3BTusrmSMDfET5yAiUCzbosAZrF15E3QahzG/SLsuGXDv4QVy/lgwlhThaFNvL5kkS09w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.10.2",
|
||||
"@iconify/react": "^5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.3.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.3.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-hook-form": {
|
||||
"version": "7.59.0",
|
||||
"resolved": "https://registry.npmmirror.com/react-hook-form/-/react-hook-form-7.59.0.tgz",
|
||||
|
||||
@ -91,6 +91,7 @@
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "18.2.0",
|
||||
"react-grid-layout": "^1.5.1",
|
||||
"react-h5-audio-player": "^3.10.0",
|
||||
"react-hook-form": "^7.53.0",
|
||||
"react-intersection-observer": "^9.16.0",
|
||||
"react-joyride": "^2.9.3",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user