forked from 77media/video-flow
重构模板故事模式,添加音频录制组件,优化角色头像上传与分析功能
This commit is contained in:
parent
47a85428f5
commit
84b6662a51
90
README.md
90
README.md
@ -2,97 +2,7 @@
|
||||
|
||||
一个基于Next.js的视频创作工具,支持多种创作模式和故事模板。
|
||||
|
||||
## 故事模板组件
|
||||
|
||||
### 功能特性
|
||||
|
||||
故事模板交互组件 (`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接口替换模拟数据
|
||||
- 添加音频预览播放器功能
|
||||
- 实现上传进度条和状态提示
|
||||
- 支持模板收藏和最近使用功能
|
||||
- 添加模板搜索和分类筛选
|
||||
|
||||
## 启动开发服务器
|
||||
|
||||
|
||||
@ -60,6 +60,8 @@ interface UseImageStoryService {
|
||||
setCharactersAnalysis: Dispatch<SetStateAction<CharacterAnalysis[]>>;
|
||||
/** 设置原始用户描述 */
|
||||
setOriginalUserDescription: Dispatch<SetStateAction<string>>;
|
||||
/** 上传人物头像并分析特征,替换旧的角色数据 */
|
||||
uploadCharacterAvatarAndAnalyzeFeatures: (characterName: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useImageStoryServiceHook = (): UseImageStoryService => {
|
||||
@ -479,6 +481,42 @@ export const useImageStoryServiceHook = (): UseImageStoryService => {
|
||||
console.error("创建电影项目失败:", error);
|
||||
}
|
||||
}, [hasAnalyzed, storyContent, charactersAnalysis, selectedCategory, activeImageUrl]);
|
||||
|
||||
/**
|
||||
* 上传人物头像并分析特征,替换旧的角色数据
|
||||
* @param {string} characterName - 角色名称
|
||||
*/
|
||||
const uploadCharacterAvatarAndAnalyzeFeatures = useCallback(async (characterName: string): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
// 调用用例处理人物头像上传和特征分析
|
||||
const result = await imageStoryUseCase.uploadCharacterAvatarAndAnalyzeFeatures(
|
||||
uploadFile
|
||||
);
|
||||
|
||||
// 用新的头像和特征描述替换旧的角色数据
|
||||
setCharactersAnalysis((prev) =>
|
||||
prev.map((char) =>
|
||||
char.role_name === characterName
|
||||
? {
|
||||
...char,
|
||||
crop_url: result.crop_url,
|
||||
whisk_caption: result.whisk_caption
|
||||
}
|
||||
: char
|
||||
)
|
||||
);
|
||||
|
||||
console.log("人物头像和特征描述更新成功:", result);
|
||||
} catch (error) {
|
||||
console.error("人物头像上传和特征分析失败:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [imageStoryUseCase, uploadFile]);
|
||||
|
||||
return {
|
||||
imageStory,
|
||||
activeImageUrl,
|
||||
@ -501,5 +539,6 @@ export const useImageStoryServiceHook = (): UseImageStoryService => {
|
||||
resetImageStory,
|
||||
setOriginalUserDescription,
|
||||
actionMovie,
|
||||
uploadCharacterAvatarAndAnalyzeFeatures,
|
||||
};
|
||||
};
|
||||
|
||||
@ -62,8 +62,81 @@ export const useTemplateStoryServiceHook = (): UseTemplateStoryService => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const templates = await templateStoryUseCase.getTemplateStoryList();
|
||||
// const templates = await templateStoryUseCase.getTemplateStoryList();
|
||||
|
||||
const templates = await new Promise<StoryTemplateEntity[]>((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve([
|
||||
{
|
||||
id: '1',
|
||||
name: '奇幻冒险故事',
|
||||
imageUrl: ['/assets/3dr_howlcastle.png', '/assets/3dr_spirited.jpg'],
|
||||
generateText: '一个关于勇气与友谊的奇幻冒险故事,主角在神秘世界中寻找失落的宝藏,结识了各种奇特的生物和伙伴。',
|
||||
storyRole: [
|
||||
{
|
||||
role_name: '艾莉娅',
|
||||
photo_url: '/assets/3dr_chihiro.png',
|
||||
voice_url:""
|
||||
},
|
||||
{
|
||||
role_name: '魔法师梅林',
|
||||
photo_url: '/assets/3dr_mono.png',
|
||||
voice_url:""
|
||||
},
|
||||
{
|
||||
role_name: '守护者龙',
|
||||
photo_url: '/assets/3dr_howlbg.jpg',
|
||||
voice_url:""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: '科幻探索之旅',
|
||||
imageUrl: ['/assets/3dr_monobg.jpg'],
|
||||
generateText: '未来世界的太空探索故事,人类在浩瀚宇宙中寻找新的家园,面对未知的挑战和外星文明的接触。',
|
||||
storyRole: [
|
||||
{
|
||||
role_name: '船长凯特',
|
||||
photo_url: '/assets/3dr_chihiro.png',
|
||||
voice_url:""
|
||||
},
|
||||
{
|
||||
role_name: 'AI助手诺娃',
|
||||
photo_url: '/assets/3dr_mono.png',
|
||||
voice_url:""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: '温馨家庭喜剧',
|
||||
imageUrl: ['/assets/3dr_spirited.jpg'],
|
||||
generateText: '一个充满欢笑和温情的家庭故事,讲述家庭成员之间的日常趣事,以及如何一起面对生活中的小挑战。',
|
||||
storyRole: [
|
||||
{
|
||||
role_name: '妈妈莉莉',
|
||||
photo_url: '/assets/3dr_chihiro.png',
|
||||
voice_url:""
|
||||
},
|
||||
{
|
||||
role_name: '爸爸汤姆',
|
||||
photo_url: '/assets/3dr_mono.png',
|
||||
voice_url:""
|
||||
},
|
||||
{
|
||||
role_name: '孩子小杰',
|
||||
photo_url: '/assets/3dr_howlbg.jpg',
|
||||
voice_url:""
|
||||
}
|
||||
]
|
||||
}
|
||||
]);
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
setTemplateStoryList(templates);
|
||||
setSelectedTemplate(templates[0]);
|
||||
} catch (err) {
|
||||
console.error('获取模板列表失败:', err);
|
||||
} finally {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { ImageStoryEntity } from "../domain/Entities";
|
||||
import { AIGenerateImageStory } from "@/api/movie_start";
|
||||
import { MovieStartDTO, CharacterAnalysis } from "@/api/DTO/movie_start_dto";
|
||||
import { analyzeImageDescription } from "@/api/video_flow";
|
||||
|
||||
/**
|
||||
* 图片故事用例
|
||||
@ -8,7 +9,7 @@ import { MovieStartDTO, CharacterAnalysis } from "@/api/DTO/movie_start_dto";
|
||||
*/
|
||||
export class ImageStoryUseCase {
|
||||
/** 当前图片故事数据 */
|
||||
imageStory: ImageStoryEntity = {
|
||||
imageStory: ImageStoryEntity = {
|
||||
id: "",
|
||||
imageAnalysis: "",
|
||||
roleImage: [],
|
||||
@ -18,23 +19,21 @@ export class ImageStoryUseCase {
|
||||
};
|
||||
|
||||
/** 故事梗概 */
|
||||
storyLogline: string = "";
|
||||
storyLogline: string = "";
|
||||
|
||||
/** 角色头像及名称数据 */
|
||||
charactersAnalysis: CharacterAnalysis[] = [];
|
||||
charactersAnalysis: CharacterAnalysis[] = [];
|
||||
|
||||
/** 分类数据 */
|
||||
potentialGenres: string[] = [];
|
||||
potentialGenres: string[] = [];
|
||||
|
||||
/** 是否正在分析图片 */
|
||||
isAnalyzing: boolean = false;
|
||||
isAnalyzing: boolean = false;
|
||||
|
||||
/** 是否正在上传 */
|
||||
isUploading: boolean = false;
|
||||
isUploading: boolean = false;
|
||||
constructor() {}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 设置图片故事数据
|
||||
* @param {Partial<ImageStoryEntity>} data - 要设置的图片故事数据
|
||||
@ -71,13 +70,12 @@ export class ImageStoryUseCase {
|
||||
try {
|
||||
this.isUploading = false; // 图片已上传,设置上传状态为false
|
||||
this.isAnalyzing = true;
|
||||
console.log('imageUrl', imageUrl)
|
||||
console.log("imageUrl", imageUrl);
|
||||
// 设置上传后的图片URL
|
||||
this.setImageStory({ imageUrl });
|
||||
|
||||
// 调用AI分析接口
|
||||
return await this.analyzeImageWithAI();
|
||||
|
||||
} catch (error) {
|
||||
console.error("图片分析失败:", error);
|
||||
// 分析失败时清空图片URL
|
||||
@ -92,8 +90,8 @@ export class ImageStoryUseCase {
|
||||
* 使用AI分析图片
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async analyzeImageWithAI() {
|
||||
console.log('this.imageStory.imageUrl', this.imageStory.imageUrl)
|
||||
async analyzeImageWithAI() {
|
||||
console.log("this.imageStory.imageUrl", this.imageStory.imageUrl);
|
||||
try {
|
||||
//
|
||||
// 调用AI分析接口
|
||||
@ -122,7 +120,7 @@ export class ImageStoryUseCase {
|
||||
* 解析并存储分析数据到类属性中
|
||||
* @param {MovieStartDTO} data - AI分析返回的数据
|
||||
*/
|
||||
parseAndStoreAnalysisData(data: MovieStartDTO): void {
|
||||
parseAndStoreAnalysisData(data: MovieStartDTO): void {
|
||||
// 存储故事梗概
|
||||
this.storyLogline = data.story_logline || "";
|
||||
|
||||
@ -137,25 +135,25 @@ export class ImageStoryUseCase {
|
||||
* 组合成ImageStoryEntity
|
||||
* @param {MovieStartDTO} data - AI分析返回的数据
|
||||
*/
|
||||
composeImageStoryEntity(data: MovieStartDTO): void {
|
||||
composeImageStoryEntity(data: MovieStartDTO): void {
|
||||
// 将角色数据转换为ImageStoryEntity需要的格式
|
||||
const roleImage = data.characters_analysis?.map(character => ({
|
||||
name: character.role_name,
|
||||
avatar_url: "", // 这里需要根据实际情况设置头像URL
|
||||
region: {
|
||||
x: character.region?.x || 0,
|
||||
y: character.region?.y || 0,
|
||||
width: character.region?.width || 0,
|
||||
height: character.region?.height || 0,
|
||||
},
|
||||
|
||||
})) || [];
|
||||
const roleImage =
|
||||
data.characters_analysis?.map((character) => ({
|
||||
name: character.role_name,
|
||||
avatar_url: "", // 这里需要根据实际情况设置头像URL
|
||||
region: {
|
||||
x: character.region?.x || 0,
|
||||
y: character.region?.y || 0,
|
||||
width: character.region?.width || 0,
|
||||
height: character.region?.height || 0,
|
||||
},
|
||||
})) || [];
|
||||
|
||||
// 更新ImageStoryEntity
|
||||
this.setImageStory({
|
||||
...this.imageStory,
|
||||
imageAnalysis: data.story_logline || "",
|
||||
storyType: data.potential_genres[0] || "", // 使用第一个分类作为故事类型
|
||||
storyType: data.potential_genres[0] || "", // 使用第一个分类作为故事类型
|
||||
roleImage,
|
||||
});
|
||||
}
|
||||
@ -180,14 +178,14 @@ export class ImageStoryUseCase {
|
||||
* @param {CharacterAnalysis[]} characters - 角色分析数据
|
||||
*/
|
||||
processCharacterData(characters: CharacterAnalysis[]): void {
|
||||
this.charactersAnalysis = characters.map(character => ({
|
||||
this.charactersAnalysis = characters.map((character) => ({
|
||||
...character,
|
||||
region: {
|
||||
x: character.region?.x || 0,
|
||||
y: character.region?.y || 0,
|
||||
width: character.region?.width || 0,
|
||||
height: character.region?.height || 0,
|
||||
}
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
@ -196,8 +194,12 @@ export class ImageStoryUseCase {
|
||||
* @param {string} characterName - 角色名称
|
||||
* @returns {CharacterAnalysis['region'] | null} 角色区域坐标,如果未找到则返回null
|
||||
*/
|
||||
getCharacterRegion(characterName: string): CharacterAnalysis['region'] | null {
|
||||
const character = this.charactersAnalysis.find(char => char.role_name === characterName);
|
||||
getCharacterRegion(
|
||||
characterName: string
|
||||
): CharacterAnalysis["region"] | null {
|
||||
const character = this.charactersAnalysis.find(
|
||||
(char) => char.role_name === characterName
|
||||
);
|
||||
return character ? character.region : null;
|
||||
}
|
||||
|
||||
@ -207,7 +209,9 @@ export class ImageStoryUseCase {
|
||||
* @param {string} avatarUrl - 头像URL
|
||||
*/
|
||||
updateCharacterAvatar(characterName: string, avatarUrl: string): void {
|
||||
const character = this.charactersAnalysis.find(char => char.role_name === characterName);
|
||||
const character = this.charactersAnalysis.find(
|
||||
(char) => char.role_name === characterName
|
||||
);
|
||||
if (character) {
|
||||
// 更新角色头像URL(这里需要根据实际的数据结构来调整)
|
||||
// 由于CharacterAnalysis接口中没有avatar_url字段,这里只是示例
|
||||
@ -220,7 +224,80 @@ export class ImageStoryUseCase {
|
||||
* @returns {string[]} 角色名称数组
|
||||
*/
|
||||
getAllCharacterNames(): string[] {
|
||||
return this.charactersAnalysis.map(char => char.role_name);
|
||||
return this.charactersAnalysis.map((char) => char.role_name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传人物头像并分析特征,替换旧的角色数据
|
||||
* @param {Function} uploadFile - 文件上传函数
|
||||
* @returns {Promise<{crop_url: string, whisk_caption: string}>} 返回新的头像URL和特征描述
|
||||
*/
|
||||
async uploadCharacterAvatarAndAnalyzeFeatures(
|
||||
uploadFile: (file: File) => Promise<string>
|
||||
): Promise<{ crop_url: string; whisk_caption: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 创建文件输入元素
|
||||
const fileInput = document.createElement("input");
|
||||
fileInput.type = "file";
|
||||
fileInput.accept = "image/*";
|
||||
fileInput.style.display = "none";
|
||||
|
||||
fileInput.onchange = async (e) => {
|
||||
try {
|
||||
const target = e.target as HTMLInputElement;
|
||||
if (target.files && target.files[0]) {
|
||||
const file = target.files[0];
|
||||
|
||||
// 直接在这里处理上传和分析逻辑
|
||||
try {
|
||||
// 1. 上传人物头像图片
|
||||
const imageUrl = await uploadFile(file);
|
||||
|
||||
// 2. 调用AI分析接口获取人物特征描述
|
||||
const analysisResult = await analyzeImageDescription({
|
||||
image_url: imageUrl,
|
||||
});
|
||||
|
||||
if (!analysisResult.successful || !analysisResult.data) {
|
||||
throw new Error("人物特征分析失败");
|
||||
}
|
||||
|
||||
// 3. 返回新的头像URL和特征描述,用于替换旧数据
|
||||
const result = {
|
||||
crop_url: imageUrl,
|
||||
whisk_caption: analysisResult.data.description,
|
||||
};
|
||||
|
||||
// 清理临时元素
|
||||
document.body.removeChild(fileInput);
|
||||
resolve(result);
|
||||
} catch (error) {
|
||||
// 清理临时元素
|
||||
if (document.body.contains(fileInput)) {
|
||||
document.body.removeChild(fileInput);
|
||||
}
|
||||
reject(error);
|
||||
}
|
||||
} else {
|
||||
reject(new Error("未选择文件"));
|
||||
}
|
||||
} catch (error) {
|
||||
// 清理临时元素
|
||||
if (document.body.contains(fileInput)) {
|
||||
document.body.removeChild(fileInput);
|
||||
}
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
fileInput.oncancel = () => {
|
||||
document.body.removeChild(fileInput);
|
||||
reject(new Error("用户取消选择"));
|
||||
};
|
||||
|
||||
// 添加到DOM并触发点击
|
||||
document.body.appendChild(fileInput);
|
||||
fileInput.click();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -139,11 +139,6 @@ export function AudioRecorder({
|
||||
setIsPlaying(!isPlaying);
|
||||
};
|
||||
|
||||
// 音量控制
|
||||
const toggleMute = () => {
|
||||
setIsMuted(!isMuted);
|
||||
};
|
||||
|
||||
// 音量调节
|
||||
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newVolume = parseFloat(e.target.value);
|
||||
@ -165,72 +160,77 @@ export function AudioRecorder({
|
||||
<>
|
||||
<style>{audioRecorderStyles}</style>
|
||||
<div className="relative bg-white/[0.05] border border-white/[0.1] rounded-lg p-4">
|
||||
{/* 头部 - 只显示关闭按钮 */}
|
||||
{showCloseButton && (
|
||||
<div className="flex justify-end mb-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-white/60 hover:text-white/80 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* 头部 - 只显示关闭按钮 */}
|
||||
{showCloseButton && (
|
||||
<div className="flex justify-end mb-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-white/60 hover:text-white/80 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 主要内容区域 */}
|
||||
<div className="flex items-center justify-center min-h-[60px]">
|
||||
{/* 主要内容区域 */}
|
||||
<div className="flex items-center justify-center min-h-[60px]">
|
||||
{mode === "upload" ? (
|
||||
// 上传模式
|
||||
<div className="text-center w-full">
|
||||
<Tooltip
|
||||
title="Please clearly read the story description above and record a 15-second audio file for upload"
|
||||
placement="top"
|
||||
overlayClassName="max-w-xs"
|
||||
>
|
||||
<div>
|
||||
<AntdUpload.Dragger
|
||||
accept="audio/*"
|
||||
beforeUpload={() => false}
|
||||
customRequest={async ({ file, onSuccess, onError }) => {
|
||||
try {
|
||||
const fileObj = file as File;
|
||||
console.log("开始上传文件:", fileObj.name, fileObj.type, fileObj.size);
|
||||
<Tooltip
|
||||
title="Please clearly read the story description above and record a 15-second audio file for upload"
|
||||
placement="top"
|
||||
overlayClassName="max-w-xs"
|
||||
>
|
||||
<div>
|
||||
<AntdUpload.Dragger
|
||||
accept="audio/*"
|
||||
beforeUpload={() => false}
|
||||
customRequest={async ({ file, onSuccess, onError }) => {
|
||||
try {
|
||||
const fileObj = file as File;
|
||||
console.log(
|
||||
"开始上传文件:",
|
||||
fileObj.name,
|
||||
fileObj.type,
|
||||
fileObj.size
|
||||
);
|
||||
|
||||
if (fileObj && fileObj.type.startsWith("audio/")) {
|
||||
// 使用 hook 上传文件到七牛云
|
||||
console.log("调用 uploadFile hook...");
|
||||
const uploadedUrl = await uploadFile(fileObj);
|
||||
console.log("上传成功,URL:", uploadedUrl);
|
||||
if (fileObj && fileObj.type.startsWith("audio/")) {
|
||||
// 使用 hook 上传文件到七牛云
|
||||
console.log("调用 uploadFile hook...");
|
||||
const uploadedUrl = await uploadFile(fileObj);
|
||||
console.log("上传成功,URL:", uploadedUrl);
|
||||
|
||||
// 上传成功后,调用回调函数
|
||||
onAudioRecorded(fileObj, uploadedUrl);
|
||||
onSuccess?.(uploadedUrl);
|
||||
} else {
|
||||
console.log("文件类型不是音频:", fileObj?.type);
|
||||
const error = new Error("文件类型不是音频文件");
|
||||
onError?.(error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("上传失败:", error);
|
||||
// 上传失败时直接报告错误,不使用本地文件作为备选
|
||||
onError?.(error as Error);
|
||||
}
|
||||
}}
|
||||
showUploadList={false}
|
||||
className="bg-transparent border-dashed border-white/20 hover:border-white/40"
|
||||
disabled={isUploading}
|
||||
>
|
||||
<div className="text-2xl text-white/40 mb-2">
|
||||
<InboxOutlined />
|
||||
</div>
|
||||
<div className="text-xs text-white/60">
|
||||
{isUploading
|
||||
? "Uploading..."
|
||||
: "Drag audio file here or click to upload"}
|
||||
</div>
|
||||
</AntdUpload.Dragger>
|
||||
</div>
|
||||
</Tooltip>
|
||||
// 上传成功后,调用回调函数
|
||||
onAudioRecorded(fileObj, uploadedUrl);
|
||||
onSuccess?.(uploadedUrl);
|
||||
} else {
|
||||
console.log("文件类型不是音频:", fileObj?.type);
|
||||
const error = new Error("文件类型不是音频文件");
|
||||
onError?.(error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("上传失败:", error);
|
||||
// 上传失败时直接报告错误,不使用本地文件作为备选
|
||||
onError?.(error as Error);
|
||||
}
|
||||
}}
|
||||
showUploadList={false}
|
||||
className="bg-transparent border-dashed border-white/20 hover:border-white/40"
|
||||
disabled={isUploading}
|
||||
>
|
||||
<div className="text-2xl text-white/40 mb-2">
|
||||
<InboxOutlined />
|
||||
</div>
|
||||
<div className="text-xs text-white/60">
|
||||
{isUploading
|
||||
? "Uploading..."
|
||||
: "Drag audio file here or click to upload"}
|
||||
</div>
|
||||
</AntdUpload.Dragger>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : (
|
||||
// 录制模式
|
||||
@ -264,19 +264,19 @@ export function AudioRecorder({
|
||||
<div className="text-xs text-white/60 mb-3">
|
||||
Click to start recording
|
||||
</div>
|
||||
<Tooltip
|
||||
title="Please clearly read the story description above and record a 15-second audio"
|
||||
placement="top"
|
||||
overlayClassName="max-w-xs"
|
||||
>
|
||||
<button
|
||||
onClick={startRecording}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors text-sm mx-auto"
|
||||
>
|
||||
<Mic className="w-3 h-3" />
|
||||
<span>Record</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
title="Please clearly read the story description above and record a 15-second audio"
|
||||
placement="top"
|
||||
classNames={{ root: "max-w-xs" }}
|
||||
>
|
||||
<button
|
||||
onClick={startRecording}
|
||||
className="flex items-center justify-center w-12 h-12 bg-red-500 text-white rounded-full hover:bg-red-600 transition-all duration-200 shadow-lg hover:shadow-xl mx-auto"
|
||||
data-alt="record-button"
|
||||
>
|
||||
<Mic className="w-5 h-5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -321,24 +321,24 @@ export function AudioRecorder({
|
||||
<>
|
||||
<style>{audioRecorderStyles}</style>
|
||||
<div className="relative bg-white/[0.05] border border-white/[0.1] rounded-lg p-4">
|
||||
{/* 头部 - 只显示操作按钮 */}
|
||||
<div className="flex justify-end gap-2 mb-3">
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="text-white/60 hover:text-red-400 transition-colors"
|
||||
title="Delete audio"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
{showCloseButton && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-white/60 hover:text-white/80 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* 头部 - 只显示操作按钮 */}
|
||||
<div className="flex justify-end gap-2 mb-3">
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="text-white/60 hover:text-red-400 transition-colors"
|
||||
title="Delete audio"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
{showCloseButton && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-white/60 hover:text-white/80 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* WaveSurfer 波形图区域 */}
|
||||
<div className="mb-4">
|
||||
@ -369,34 +369,20 @@ export function AudioRecorder({
|
||||
|
||||
{/* 音频设置 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
className="text-white/60 hover:text-white/80 transition-colors"
|
||||
title={isMuted ? "Unmute" : "Mute"}
|
||||
>
|
||||
{isMuted ? (
|
||||
<MicOff className="w-4 h-4" />
|
||||
) : (
|
||||
<Mic className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={volume}
|
||||
onChange={handleVolumeChange}
|
||||
className="w-16 h-1 bg-white/20 rounded-lg appearance-none cursor-pointer slider"
|
||||
/>
|
||||
<span className="text-xs text-white/60 w-8">
|
||||
{Math.round(volume * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={volume}
|
||||
onChange={handleVolumeChange}
|
||||
className="w-16 h-1 !bg-white/20 rounded-lg appearance-none cursor-pointer slider"
|
||||
/>
|
||||
<span className="text-xs text-white/60 w-8">
|
||||
{Math.round(volume * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-white/40">1x</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
@ -11,140 +11,25 @@ import {
|
||||
Crown,
|
||||
Clapperboard,
|
||||
Globe,
|
||||
AudioLines,
|
||||
Clock,
|
||||
Trash2,
|
||||
Plus,
|
||||
LayoutTemplate,
|
||||
ImagePlay,
|
||||
Sparkles,
|
||||
RotateCcw,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
import { Dropdown, Modal, Tooltip, Upload, Image, Spin } from "antd";
|
||||
import { PlusOutlined, UploadOutlined, EyeOutlined } from "@ant-design/icons";
|
||||
import { Dropdown, Modal, Tooltip, Upload, Spin } 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 { createScriptEpisodeNew } from "@/api/script_episode";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { EditorContent, useEditor } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { HighlightTextExtension } from "@/components/ui/main-editor/HighlightText";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import { createMovieProjectV1 } from "@/api/video_flow";
|
||||
import { useLoadScriptText, useUploadFile } from "@/app/service/domain/service";
|
||||
|
||||
// 自定义音频播放器样式
|
||||
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);
|
||||
}
|
||||
|
||||
/* 文本截断类 */
|
||||
.line-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
`;
|
||||
import { ActionButton } from "../common/ActionButton";
|
||||
import { HighlightEditor } from "../common/HighlightEditor";
|
||||
|
||||
/**模板故事模式弹窗组件 */
|
||||
const RenderTemplateStoryMode = ({
|
||||
@ -223,7 +108,7 @@ const RenderTemplateStoryMode = ({
|
||||
// 模板列表渲染
|
||||
const templateListRender = () => {
|
||||
return (
|
||||
<div className="w-1/3 p-6 border-r border-white/[0.1]">
|
||||
<div className="w-1/3 p-4 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) => (
|
||||
@ -255,9 +140,9 @@ const RenderTemplateStoryMode = ({
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white"></div>
|
||||
</div>
|
||||
) : selectedTemplate ? (
|
||||
<div>
|
||||
<div className="relative h-full">
|
||||
{/* 模板信息头部 - 增加顶部空间 */}
|
||||
<div className="flex gap-6 p-6 border-b border-white/[0.1]">
|
||||
<div className="flex gap-3 py-4 border-b border-white/[0.1] min-h-[300px]">
|
||||
{/* 左侧图片 */}
|
||||
<div className="w-1/3">
|
||||
<div
|
||||
@ -277,14 +162,14 @@ const RenderTemplateStoryMode = ({
|
||||
<div className="flex-1 flex flex-col">
|
||||
<h2
|
||||
data-alt="template-title"
|
||||
className="text-2xl font-bold text-white mb-6"
|
||||
className="text-2xl font-bold text-white mb-4"
|
||||
>
|
||||
{selectedTemplate.name}
|
||||
</h2>
|
||||
<div className="flex-1 overflow-y-auto max-h-80 pr-2">
|
||||
<div className="flex-1 overflow-y-auto max-h-96 pr-3">
|
||||
<p
|
||||
data-alt="template-description"
|
||||
className="text-gray-300 text-base leading-relaxed"
|
||||
className="text-gray-300 text-sm leading-relaxed"
|
||||
>
|
||||
{selectedTemplate.generateText}
|
||||
</p>
|
||||
@ -293,7 +178,7 @@ const RenderTemplateStoryMode = ({
|
||||
</div>
|
||||
|
||||
{/* 角色自定义部分 - 精简布局 */}
|
||||
<div className="p-6">
|
||||
<div className="p-4">
|
||||
<h3
|
||||
data-alt="roles-section-title"
|
||||
className="text-lg font-semibold text-white mb-4"
|
||||
@ -409,7 +294,7 @@ const RenderTemplateStoryMode = ({
|
||||
</div>
|
||||
|
||||
{/* 弹窗底部操作 - 只保留 Action 按钮 */}
|
||||
<div className="relative group flex justify-end mt-10">
|
||||
{/* <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>
|
||||
|
||||
@ -434,6 +319,13 @@ const RenderTemplateStoryMode = ({
|
||||
</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 className=" absolute bottom-0 right-0">
|
||||
<ActionButton
|
||||
isCreating={localLoading}
|
||||
handleCreateVideo={handleConfirm}
|
||||
icon={<Clapperboard className="w-5 h-5" />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@ -448,7 +340,6 @@ const RenderTemplateStoryMode = ({
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<style>{customAudioPlayerStyles}</style>
|
||||
<Modal
|
||||
open={isOpen}
|
||||
onCancel={() => {
|
||||
@ -740,38 +631,6 @@ export function ChatInputBox() {
|
||||
);
|
||||
}
|
||||
|
||||
// 创建视频按钮
|
||||
const ActionButton = ({
|
||||
isCreating,
|
||||
handleCreateVideo,
|
||||
icon,
|
||||
}: {
|
||||
isCreating: boolean;
|
||||
handleCreateVideo: () => void;
|
||||
icon: React.ReactNode;
|
||||
}) => {
|
||||
return (
|
||||
<div className="relative group">
|
||||
<div
|
||||
data-alt="action-button"
|
||||
className="relative w-12 h-12 opacity-90 cursor-pointer overflow-hidden rounded-xl bg-black z-10"
|
||||
onClick={isCreating ? undefined : handleCreateVideo}
|
||||
>
|
||||
<div className="absolute z-10 -translate-x-12 group-hover:translate-x-12 transition-all duration-700 h-full w-12 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-xl inset-0.5 bg-black">
|
||||
<button
|
||||
name="text"
|
||||
className="w-full h-full opacity-90 rounded-xl bg-black flex items-center justify-center"
|
||||
>
|
||||
{isCreating ? <Loader2 className="w-5 h-5 animate-spin" /> : icon}
|
||||
</button>
|
||||
</div>
|
||||
<div className="absolute duration-1000 group-hover:animate-spin w-full h-12 bg-gradient-to-r from-[rgb(106,244,249)] to-[rgb(199,59,255)] blur-[20px]"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 配置选项组件
|
||||
* 提供视频创建的各种配置选项,位于输入框下方
|
||||
@ -872,135 +731,6 @@ const ConfigOptions = ({
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 角色高亮编辑器组件
|
||||
* 使用 Tiptap 实现角色名称高亮和文本编辑功能
|
||||
*/
|
||||
const RoleHighlightEditor = ({
|
||||
content,
|
||||
onContentChange,
|
||||
}: {
|
||||
content: string;
|
||||
onContentChange: (content: string) => void;
|
||||
}) => {
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
HighlightTextExtension,
|
||||
Placeholder.configure({
|
||||
placeholder:
|
||||
"Share your creative ideas about the image and let AI create a movie story for you...",
|
||||
emptyEditorClass: "is-editor-empty",
|
||||
}),
|
||||
],
|
||||
content: "",
|
||||
// 简化:移除复杂的 onUpdate 逻辑,只处理基本的文本变化
|
||||
onUpdate: ({ editor }) => {
|
||||
const textContent = editor.getText();
|
||||
if (!textContent.trim()) {
|
||||
onContentChange("");
|
||||
return;
|
||||
}
|
||||
// 直接传递文本内容,不进行复杂的标签重建
|
||||
onContentChange(textContent);
|
||||
},
|
||||
editorProps: {
|
||||
handleKeyDown: (view, event) => {
|
||||
const { from, to } = view.state.selection;
|
||||
const doc = view.state.doc;
|
||||
|
||||
// 检查光标前后是否有角色标签
|
||||
const textBefore =
|
||||
from > 0 ? doc.textBetween(Math.max(0, from - 50), from) : "";
|
||||
const textAfter =
|
||||
to < doc.content.size
|
||||
? doc.textBetween(to, Math.min(doc.content.size, to + 50))
|
||||
: "";
|
||||
// 匹配新的角色标签格式 <role id="C1">Dezhong Huang</role>
|
||||
const beforeMatch = textBefore.match(/<role[^>]*>[^<]*$/);
|
||||
const afterMatch = textAfter.match(/^[^>]*<\/role>/);
|
||||
|
||||
// 如果光标在角色标签内,阻止输入(只允许删除操作)
|
||||
if (beforeMatch || afterMatch) {
|
||||
if (event.key !== "Backspace" && event.key !== "Delete") {
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
},
|
||||
immediatelyRender: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (editor) {
|
||||
if (!content || content.trim() === "") {
|
||||
editor.commands.clearContent(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// 将带标签的内容转换为高亮显示(支持新的角色标签格式)
|
||||
const htmlContent = content.replace(
|
||||
/<role[^>]*>([^<]+)<\/role>/g,
|
||||
'<highlight-text type="role" text="$1" color="blue">$1</highlight-text>'
|
||||
);
|
||||
editor.commands.setContent(htmlContent, { emitUpdate: false });
|
||||
}
|
||||
}, [content, editor]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-w-0 relative pr-20">
|
||||
<style jsx>{`
|
||||
.role-name-highlight {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white !important;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
margin: 0 2px;
|
||||
user-select: none;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 移除 Tiptap 编辑器的默认焦点样式 */
|
||||
.ProseMirror:focus {
|
||||
outline: none !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.ProseMirror:focus-visible {
|
||||
outline: none !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* 移除编辑器容器的焦点样式 */
|
||||
.ProseMirror-focused {
|
||||
outline: none !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* 确保编辑器内容区域没有边框和轮廓 */
|
||||
.ProseMirror {
|
||||
outline: none !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
`}</style>
|
||||
<EditorContent
|
||||
editor={editor}
|
||||
className="w-full bg-transparent border-none outline-none resize-none text-white placeholder:text-white/40 text-sm leading-relaxed min-h-[60px] focus:outline-none focus:ring-0 focus:border-0"
|
||||
style={{ outline: "none", border: "none", boxShadow: "none" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 图片故事弹窗组件
|
||||
* 提供图片上传、AI分析和故事生成功能,支持动态UI变化
|
||||
@ -1042,6 +772,7 @@ const PhotoStoryModal = ({
|
||||
setCharactersAnalysis,
|
||||
originalUserDescription,
|
||||
actionMovie,
|
||||
uploadCharacterAvatarAndAnalyzeFeatures,
|
||||
} = useImageStoryServiceHook();
|
||||
const { loadingText } = useLoadScriptText(isLoading);
|
||||
const { uploadFile } = useUploadFile();
|
||||
@ -1222,47 +953,12 @@ const PhotoStoryModal = ({
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// 创建隐藏的文件输入框
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = "image/*";
|
||||
input.style.display = "none";
|
||||
|
||||
input.onchange = async (event) => {
|
||||
const target =
|
||||
event.target as HTMLInputElement;
|
||||
const file = target.files?.[0];
|
||||
if (file) {
|
||||
try {
|
||||
// 使用七牛云上传
|
||||
const newImageUrl = await uploadFile(
|
||||
file
|
||||
);
|
||||
|
||||
// 更新角色分析中的图片URL
|
||||
setCharactersAnalysis((prev) =>
|
||||
prev.map((char) =>
|
||||
char.role_name === avatar.name
|
||||
? { ...char, crop_url: newImageUrl }
|
||||
: char
|
||||
)
|
||||
);
|
||||
|
||||
// 清理临时元素
|
||||
document.body.removeChild(input);
|
||||
} catch (error) {
|
||||
console.error("上传图片失败:", error);
|
||||
// 清理临时元素
|
||||
if (document.body.contains(input)) {
|
||||
document.body.removeChild(input);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 添加到DOM并触发点击
|
||||
document.body.appendChild(input);
|
||||
input.click();
|
||||
// 使用新的上传人物头像并分析特征方法
|
||||
uploadCharacterAvatarAndAnalyzeFeatures(
|
||||
avatar.name
|
||||
).catch((error) => {
|
||||
console.error("上传人物头像失败:", error);
|
||||
});
|
||||
}}
|
||||
className="absolute inset-0 bg-black/20 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center"
|
||||
>
|
||||
@ -1320,9 +1016,11 @@ const PhotoStoryModal = ({
|
||||
|
||||
<div className="flex items-start gap-4 mt-2 relative">
|
||||
{/* 文本输入框 */}
|
||||
<RoleHighlightEditor
|
||||
<HighlightEditor
|
||||
content={storyContent}
|
||||
onContentChange={updateStoryContent}
|
||||
type={"role"}
|
||||
placeholder="Share your creative ideas about the image and let AI create a movie story for you..."
|
||||
/>
|
||||
<div className="absolute bottom-1 right-0 flex gap-2">
|
||||
{!hasAnalyzed ? (
|
||||
33
components/common/ActionButton.tsx
Normal file
33
components/common/ActionButton.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
// 创建流光钮
|
||||
export function ActionButton({
|
||||
isCreating,
|
||||
handleCreateVideo,
|
||||
icon,
|
||||
}: {
|
||||
isCreating: boolean;
|
||||
handleCreateVideo: () => void;
|
||||
icon: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative group">
|
||||
<div
|
||||
data-alt="action-button"
|
||||
className="relative w-12 h-12 opacity-90 cursor-pointer overflow-hidden rounded-xl bg-black z-10"
|
||||
onClick={isCreating ? undefined : handleCreateVideo}
|
||||
>
|
||||
<div className="absolute z-10 -translate-x-12 group-hover:translate-x-12 transition-all duration-700 h-full w-12 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-xl inset-0.5 bg-black">
|
||||
<button
|
||||
name="text"
|
||||
className="w-full h-full opacity-90 rounded-xl bg-black flex items-center justify-center"
|
||||
>
|
||||
{isCreating ? <Loader2 className="w-5 h-5 animate-spin" /> : icon}
|
||||
</button>
|
||||
</div>
|
||||
<div className="absolute duration-1000 group-hover:animate-spin w-full h-12 bg-gradient-to-r from-[rgb(106,244,249)] to-[rgb(199,59,255)] blur-[20px]"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
139
components/common/HighlightEditor.tsx
Normal file
139
components/common/HighlightEditor.tsx
Normal file
@ -0,0 +1,139 @@
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { useEffect } from "react";
|
||||
import { HighlightTextExtension } from "../ui/main-editor/HighlightText";
|
||||
|
||||
/**
|
||||
* 高亮编辑器组件
|
||||
* 使用 Tiptap 实现高亮和文本编辑功能
|
||||
* 让 文本中的<type>xxxx</type>标签高亮
|
||||
*/
|
||||
export const HighlightEditor = ({
|
||||
content,
|
||||
onContentChange,
|
||||
type,
|
||||
placeholder,
|
||||
}: {
|
||||
/** 内容 */
|
||||
content: string;
|
||||
/** 内容变化回调 */
|
||||
onContentChange: (content: string) => void;
|
||||
/** 标签类型*/
|
||||
type: string;
|
||||
/**提示语 */
|
||||
placeholder: string;
|
||||
}) => {
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
HighlightTextExtension,
|
||||
Placeholder.configure({
|
||||
placeholder,
|
||||
emptyEditorClass: "is-editor-empty",
|
||||
}),
|
||||
],
|
||||
content: "",
|
||||
onUpdate: ({ editor }) => {
|
||||
const textContent = editor.getText();
|
||||
if (!textContent.trim()) {
|
||||
onContentChange("");
|
||||
return;
|
||||
}
|
||||
// 直接传递文本内容,不进行复杂的标签重建
|
||||
onContentChange(textContent);
|
||||
},
|
||||
editorProps: {
|
||||
handleKeyDown: (view, event) => {
|
||||
const { from, to } = view.state.selection;
|
||||
const doc = view.state.doc;
|
||||
// 检查光标前后是否有标签
|
||||
const textBefore =
|
||||
from > 0 ? doc.textBetween(Math.max(0, from - 50), from) : "";
|
||||
const textAfter =
|
||||
to < doc.content.size
|
||||
? doc.textBetween(to, Math.min(doc.content.size, to + 50))
|
||||
: "";
|
||||
const beforeMatch = textBefore.match(new RegExp(`<${type}[^>]*>[^<]*$`));
|
||||
const afterMatch = textAfter.match(new RegExp(`^[^>]*<\\/${type}>`));
|
||||
|
||||
// 只允许删除操作)
|
||||
if (beforeMatch || afterMatch) {
|
||||
if (event.key !== "Backspace" && event.key !== "Delete") {
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
},
|
||||
immediatelyRender: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (editor) {
|
||||
if (!content || content.trim() === "") {
|
||||
editor.commands.clearContent(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// 将带标签的内容转换为高亮显示(支持新的标签格式)
|
||||
const htmlContent = content.replace(
|
||||
new RegExp(`<${type}[^>]*>([^<]+)<\/${type}>`, "g"),
|
||||
'<highlight-text type="' + type + '" text="$1" color="blue">$1</highlight-text>'
|
||||
);
|
||||
editor.commands.setContent(htmlContent, { emitUpdate: false });
|
||||
}
|
||||
}, [content, editor]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-w-0 relative pr-20">
|
||||
<style jsx>{`
|
||||
.${type}-name-highlight {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white !important;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
margin: 0 2px;
|
||||
user-select: none;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 移除 Tiptap 编辑器的默认焦点样式 */
|
||||
.ProseMirror:focus {
|
||||
outline: none !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.ProseMirror:focus-visible {
|
||||
outline: none !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* 移除编辑器容器的焦点样式 */
|
||||
.ProseMirror-focused {
|
||||
outline: none !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* 确保编辑器内容区域没有边框和轮廓 */
|
||||
.ProseMirror {
|
||||
outline: none !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
`}</style>
|
||||
<EditorContent
|
||||
editor={editor}
|
||||
className="w-full bg-transparent border-none outline-none resize-none text-white placeholder:text-white/40 text-sm leading-relaxed min-h-[60px] focus:outline-none focus:ring-0 focus:border-0"
|
||||
style={{ outline: "none", border: "none", boxShadow: "none" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -7,7 +7,7 @@ import './style/create-to-video2.css';
|
||||
|
||||
import { createScriptEpisodeNew, getScriptEpisodeListNew, CreateScriptEpisodeRequest } from "@/api/script_episode";
|
||||
import { EmptyStateAnimation } from '@/components/common/EmptyStateAnimation2';
|
||||
import { ChatInputBox } from '@/components/common/ChatInputBox';
|
||||
import { ChatInputBox } from '@/components/ChatInputBox/ChatInputBox';
|
||||
|
||||
|
||||
// ideaText已迁移到ChatInputBox组件中
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user