重构模板故事模式,添加音频录制组件,优化角色头像上传与分析功能

This commit is contained in:
海龙 2025-08-19 22:22:18 +08:00
parent 47a85428f5
commit 84b6662a51
10 changed files with 535 additions and 580 deletions

View File

@ -2,97 +2,7 @@
一个基于Next.js的视频创作工具支持多种创作模式和故事模板。 一个基于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接口替换模拟数据
- 添加音频预览播放器功能
- 实现上传进度条和状态提示
- 支持模板收藏和最近使用功能
- 添加模板搜索和分类筛选
## 启动开发服务器 ## 启动开发服务器

View File

@ -60,6 +60,8 @@ interface UseImageStoryService {
setCharactersAnalysis: Dispatch<SetStateAction<CharacterAnalysis[]>>; setCharactersAnalysis: Dispatch<SetStateAction<CharacterAnalysis[]>>;
/** 设置原始用户描述 */ /** 设置原始用户描述 */
setOriginalUserDescription: Dispatch<SetStateAction<string>>; setOriginalUserDescription: Dispatch<SetStateAction<string>>;
/** 上传人物头像并分析特征,替换旧的角色数据 */
uploadCharacterAvatarAndAnalyzeFeatures: (characterName: string) => Promise<void>;
} }
export const useImageStoryServiceHook = (): UseImageStoryService => { export const useImageStoryServiceHook = (): UseImageStoryService => {
@ -479,6 +481,42 @@ export const useImageStoryServiceHook = (): UseImageStoryService => {
console.error("创建电影项目失败:", error); console.error("创建电影项目失败:", error);
} }
}, [hasAnalyzed, storyContent, charactersAnalysis, selectedCategory, activeImageUrl]); }, [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 { return {
imageStory, imageStory,
activeImageUrl, activeImageUrl,
@ -501,5 +539,6 @@ export const useImageStoryServiceHook = (): UseImageStoryService => {
resetImageStory, resetImageStory,
setOriginalUserDescription, setOriginalUserDescription,
actionMovie, actionMovie,
uploadCharacterAvatarAndAnalyzeFeatures,
}; };
}; };

View File

@ -62,8 +62,81 @@ export const useTemplateStoryServiceHook = (): UseTemplateStoryService => {
try { try {
setIsLoading(true); 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); setTemplateStoryList(templates);
setSelectedTemplate(templates[0]);
} catch (err) { } catch (err) {
console.error('获取模板列表失败:', err); console.error('获取模板列表失败:', err);
} finally { } finally {

View File

@ -1,6 +1,7 @@
import { ImageStoryEntity } from "../domain/Entities"; import { ImageStoryEntity } from "../domain/Entities";
import { AIGenerateImageStory } from "@/api/movie_start"; import { AIGenerateImageStory } from "@/api/movie_start";
import { MovieStartDTO, CharacterAnalysis } from "@/api/DTO/movie_start_dto"; 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 { export class ImageStoryUseCase {
/** 当前图片故事数据 */ /** 当前图片故事数据 */
imageStory: ImageStoryEntity = { imageStory: ImageStoryEntity = {
id: "", id: "",
imageAnalysis: "", imageAnalysis: "",
roleImage: [], 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() {} constructor() {}
/** /**
* *
* @param {Partial<ImageStoryEntity>} data - * @param {Partial<ImageStoryEntity>} data -
@ -71,13 +70,12 @@ export class ImageStoryUseCase {
try { try {
this.isUploading = false; // 图片已上传设置上传状态为false this.isUploading = false; // 图片已上传设置上传状态为false
this.isAnalyzing = true; this.isAnalyzing = true;
console.log('imageUrl', imageUrl) console.log("imageUrl", imageUrl);
// 设置上传后的图片URL // 设置上传后的图片URL
this.setImageStory({ imageUrl }); this.setImageStory({ imageUrl });
// 调用AI分析接口 // 调用AI分析接口
return await this.analyzeImageWithAI(); return await this.analyzeImageWithAI();
} catch (error) { } catch (error) {
console.error("图片分析失败:", error); console.error("图片分析失败:", error);
// 分析失败时清空图片URL // 分析失败时清空图片URL
@ -92,8 +90,8 @@ export class ImageStoryUseCase {
* 使AI分析图片 * 使AI分析图片
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async analyzeImageWithAI() { async analyzeImageWithAI() {
console.log('this.imageStory.imageUrl', this.imageStory.imageUrl) console.log("this.imageStory.imageUrl", this.imageStory.imageUrl);
try { try {
// //
// 调用AI分析接口 // 调用AI分析接口
@ -122,7 +120,7 @@ export class ImageStoryUseCase {
* *
* @param {MovieStartDTO} data - AI分析返回的数据 * @param {MovieStartDTO} data - AI分析返回的数据
*/ */
parseAndStoreAnalysisData(data: MovieStartDTO): void { parseAndStoreAnalysisData(data: MovieStartDTO): void {
// 存储故事梗概 // 存储故事梗概
this.storyLogline = data.story_logline || ""; this.storyLogline = data.story_logline || "";
@ -137,25 +135,25 @@ export class ImageStoryUseCase {
* ImageStoryEntity * ImageStoryEntity
* @param {MovieStartDTO} data - AI分析返回的数据 * @param {MovieStartDTO} data - AI分析返回的数据
*/ */
composeImageStoryEntity(data: MovieStartDTO): void { composeImageStoryEntity(data: MovieStartDTO): void {
// 将角色数据转换为ImageStoryEntity需要的格式 // 将角色数据转换为ImageStoryEntity需要的格式
const roleImage = data.characters_analysis?.map(character => ({ const roleImage =
name: character.role_name, data.characters_analysis?.map((character) => ({
avatar_url: "", // 这里需要根据实际情况设置头像URL name: character.role_name,
region: { avatar_url: "", // 这里需要根据实际情况设置头像URL
x: character.region?.x || 0, region: {
y: character.region?.y || 0, x: character.region?.x || 0,
width: character.region?.width || 0, y: character.region?.y || 0,
height: character.region?.height || 0, width: character.region?.width || 0,
}, height: character.region?.height || 0,
},
})) || []; })) || [];
// 更新ImageStoryEntity // 更新ImageStoryEntity
this.setImageStory({ this.setImageStory({
...this.imageStory, ...this.imageStory,
imageAnalysis: data.story_logline || "", imageAnalysis: data.story_logline || "",
storyType: data.potential_genres[0] || "", // 使用第一个分类作为故事类型 storyType: data.potential_genres[0] || "", // 使用第一个分类作为故事类型
roleImage, roleImage,
}); });
} }
@ -180,14 +178,14 @@ export class ImageStoryUseCase {
* @param {CharacterAnalysis[]} characters - * @param {CharacterAnalysis[]} characters -
*/ */
processCharacterData(characters: CharacterAnalysis[]): void { processCharacterData(characters: CharacterAnalysis[]): void {
this.charactersAnalysis = characters.map(character => ({ this.charactersAnalysis = characters.map((character) => ({
...character, ...character,
region: { region: {
x: character.region?.x || 0, x: character.region?.x || 0,
y: character.region?.y || 0, y: character.region?.y || 0,
width: character.region?.width || 0, width: character.region?.width || 0,
height: character.region?.height || 0, height: character.region?.height || 0,
} },
})); }));
} }
@ -196,8 +194,12 @@ export class ImageStoryUseCase {
* @param {string} characterName - * @param {string} characterName -
* @returns {CharacterAnalysis['region'] | null} null * @returns {CharacterAnalysis['region'] | null} null
*/ */
getCharacterRegion(characterName: string): CharacterAnalysis['region'] | null { getCharacterRegion(
const character = this.charactersAnalysis.find(char => char.role_name === characterName); characterName: string
): CharacterAnalysis["region"] | null {
const character = this.charactersAnalysis.find(
(char) => char.role_name === characterName
);
return character ? character.region : null; return character ? character.region : null;
} }
@ -207,7 +209,9 @@ export class ImageStoryUseCase {
* @param {string} avatarUrl - URL * @param {string} avatarUrl - URL
*/ */
updateCharacterAvatar(characterName: string, avatarUrl: string): void { 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) { if (character) {
// 更新角色头像URL这里需要根据实际的数据结构来调整 // 更新角色头像URL这里需要根据实际的数据结构来调整
// 由于CharacterAnalysis接口中没有avatar_url字段这里只是示例 // 由于CharacterAnalysis接口中没有avatar_url字段这里只是示例
@ -220,7 +224,80 @@ export class ImageStoryUseCase {
* @returns {string[]} * @returns {string[]}
*/ */
getAllCharacterNames(): 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();
});
} }
} }

View File

@ -139,11 +139,6 @@ export function AudioRecorder({
setIsPlaying(!isPlaying); setIsPlaying(!isPlaying);
}; };
// 音量控制
const toggleMute = () => {
setIsMuted(!isMuted);
};
// 音量调节 // 音量调节
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newVolume = parseFloat(e.target.value); const newVolume = parseFloat(e.target.value);
@ -165,72 +160,77 @@ export function AudioRecorder({
<> <>
<style>{audioRecorderStyles}</style> <style>{audioRecorderStyles}</style>
<div className="relative bg-white/[0.05] border border-white/[0.1] rounded-lg p-4"> <div className="relative bg-white/[0.05] border border-white/[0.1] rounded-lg p-4">
{/* 头部 - 只显示关闭按钮 */} {/* 头部 - 只显示关闭按钮 */}
{showCloseButton && ( {showCloseButton && (
<div className="flex justify-end mb-2"> <div className="flex justify-end mb-2">
<button <button
onClick={onClose} onClick={onClose}
className="text-white/60 hover:text-white/80 transition-colors" className="text-white/60 hover:text-white/80 transition-colors"
> >
<X className="w-4 h-4" /> <X className="w-4 h-4" />
</button> </button>
</div> </div>
)} )}
{/* 主要内容区域 */} {/* 主要内容区域 */}
<div className="flex items-center justify-center min-h-[60px]"> <div className="flex items-center justify-center min-h-[60px]">
{mode === "upload" ? ( {mode === "upload" ? (
// 上传模式 // 上传模式
<div className="text-center w-full"> <div className="text-center w-full">
<Tooltip <Tooltip
title="Please clearly read the story description above and record a 15-second audio file for upload" title="Please clearly read the story description above and record a 15-second audio file for upload"
placement="top" placement="top"
overlayClassName="max-w-xs" overlayClassName="max-w-xs"
> >
<div> <div>
<AntdUpload.Dragger <AntdUpload.Dragger
accept="audio/*" accept="audio/*"
beforeUpload={() => false} beforeUpload={() => false}
customRequest={async ({ file, onSuccess, onError }) => { customRequest={async ({ file, onSuccess, onError }) => {
try { try {
const fileObj = file as File; const fileObj = file as File;
console.log("开始上传文件:", fileObj.name, fileObj.type, fileObj.size); console.log(
"开始上传文件:",
fileObj.name,
fileObj.type,
fileObj.size
);
if (fileObj && fileObj.type.startsWith("audio/")) { if (fileObj && fileObj.type.startsWith("audio/")) {
// 使用 hook 上传文件到七牛云 // 使用 hook 上传文件到七牛云
console.log("调用 uploadFile hook..."); console.log("调用 uploadFile hook...");
const uploadedUrl = await uploadFile(fileObj); const uploadedUrl = await uploadFile(fileObj);
console.log("上传成功URL:", uploadedUrl); console.log("上传成功URL:", uploadedUrl);
// 上传成功后,调用回调函数 // 上传成功后,调用回调函数
onAudioRecorded(fileObj, uploadedUrl); onAudioRecorded(fileObj, uploadedUrl);
onSuccess?.(uploadedUrl); onSuccess?.(uploadedUrl);
} else { } else {
console.log("文件类型不是音频:", fileObj?.type); console.log("文件类型不是音频:", fileObj?.type);
const error = new Error("文件类型不是音频文件"); const error = new Error("文件类型不是音频文件");
onError?.(error); onError?.(error);
} }
} catch (error) { } catch (error) {
console.error("上传失败:", error); console.error("上传失败:", error);
// 上传失败时直接报告错误,不使用本地文件作为备选 // 上传失败时直接报告错误,不使用本地文件作为备选
onError?.(error as Error); onError?.(error as Error);
} }
}} }}
showUploadList={false} showUploadList={false}
className="bg-transparent border-dashed border-white/20 hover:border-white/40" className="bg-transparent border-dashed border-white/20 hover:border-white/40"
disabled={isUploading} disabled={isUploading}
> >
<div className="text-2xl text-white/40 mb-2"> <div className="text-2xl text-white/40 mb-2">
<InboxOutlined /> <InboxOutlined />
</div> </div>
<div className="text-xs text-white/60"> <div className="text-xs text-white/60">
{isUploading {isUploading
? "Uploading..." ? "Uploading..."
: "Drag audio file here or click to upload"} : "Drag audio file here or click to upload"}
</div> </div>
</AntdUpload.Dragger> </AntdUpload.Dragger>
</div> </div>
</Tooltip> </Tooltip>
</div> </div>
) : ( ) : (
// 录制模式 // 录制模式
@ -264,19 +264,19 @@ export function AudioRecorder({
<div className="text-xs text-white/60 mb-3"> <div className="text-xs text-white/60 mb-3">
Click to start recording Click to start recording
</div> </div>
<Tooltip <Tooltip
title="Please clearly read the story description above and record a 15-second audio" title="Please clearly read the story description above and record a 15-second audio"
placement="top" placement="top"
overlayClassName="max-w-xs" classNames={{ root: "max-w-xs" }}
> >
<button <button
onClick={startRecording} 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" 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-3 h-3" /> >
<span>Record</span> <Mic className="w-5 h-5" />
</button> </button>
</Tooltip> </Tooltip>
</div> </div>
)} )}
</div> </div>
@ -321,24 +321,24 @@ export function AudioRecorder({
<> <>
<style>{audioRecorderStyles}</style> <style>{audioRecorderStyles}</style>
<div className="relative bg-white/[0.05] border border-white/[0.1] rounded-lg p-4"> <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"> <div className="flex justify-end gap-2 mb-3">
<button <button
onClick={handleDelete} onClick={handleDelete}
className="text-white/60 hover:text-red-400 transition-colors" className="text-white/60 hover:text-red-400 transition-colors"
title="Delete audio" title="Delete audio"
> >
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
</button> </button>
{showCloseButton && ( {showCloseButton && (
<button <button
onClick={onClose} onClick={onClose}
className="text-white/60 hover:text-white/80 transition-colors" className="text-white/60 hover:text-white/80 transition-colors"
> >
<X className="w-4 h-4" /> <X className="w-4 h-4" />
</button> </button>
)} )}
</div> </div>
{/* WaveSurfer 波形图区域 */} {/* WaveSurfer 波形图区域 */}
<div className="mb-4"> <div className="mb-4">
@ -369,34 +369,20 @@ export function AudioRecorder({
{/* 音频设置 */} {/* 音频设置 */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-2">
<button <input
onClick={toggleMute} type="range"
className="text-white/60 hover:text-white/80 transition-colors" min="0"
title={isMuted ? "Unmute" : "Mute"} max="1"
> step="0.1"
{isMuted ? ( value={volume}
<MicOff className="w-4 h-4" /> onChange={handleVolumeChange}
) : ( className="w-16 h-1 !bg-white/20 rounded-lg appearance-none cursor-pointer slider"
<Mic className="w-4 h-4" /> />
)} <span className="text-xs text-white/60 w-8">
</button> {Math.round(volume * 100)}%
<div className="flex items-center gap-2"> </span>
<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> </div>
<div className="text-xs text-white/40">1x</div> <div className="text-xs text-white/40">1x</div>
</div> </div>
</div> </div>

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useRef, useEffect } from "react"; import { useState, useEffect } from "react";
import { import {
ChevronDown, ChevronDown,
ChevronUp, ChevronUp,
@ -11,140 +11,25 @@ import {
Crown, Crown,
Clapperboard, Clapperboard,
Globe, Globe,
AudioLines,
Clock, Clock,
Trash2, Trash2,
Plus,
LayoutTemplate, LayoutTemplate,
ImagePlay, ImagePlay,
Sparkles, Sparkles,
RotateCcw,
Settings, Settings,
} from "lucide-react"; } from "lucide-react";
import { Dropdown, Modal, Tooltip, Upload, Image, Spin } from "antd"; import { Dropdown, Modal, Tooltip, Upload, Spin } from "antd";
import { PlusOutlined, UploadOutlined, EyeOutlined } from "@ant-design/icons"; import { UploadOutlined } from "@ant-design/icons";
import { StoryTemplateEntity } from "@/app/service/domain/Entities"; import { StoryTemplateEntity } from "@/app/service/domain/Entities";
import { useImageStoryServiceHook } from "@/app/service/Interaction/ImageStoryService"; import { useImageStoryServiceHook } from "@/app/service/Interaction/ImageStoryService";
import TemplateCard from "./templateCard"; import TemplateCard from "./templateCard";
import { AudioRecorder } from "./AudioRecorder"; import { AudioRecorder } from "./AudioRecorder";
import { useTemplateStoryServiceHook } from "@/app/service/Interaction/templateStoryService"; import { useTemplateStoryServiceHook } from "@/app/service/Interaction/templateStoryService";
import { createScriptEpisodeNew } from "@/api/script_episode";
import { useRouter } from "next/navigation"; 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 { createMovieProjectV1 } from "@/api/video_flow";
import { useLoadScriptText, useUploadFile } from "@/app/service/domain/service"; import { useLoadScriptText, useUploadFile } from "@/app/service/domain/service";
import { ActionButton } from "../common/ActionButton";
// 自定义音频播放器样式 import { HighlightEditor } from "../common/HighlightEditor";
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;
}
`;
/**模板故事模式弹窗组件 */ /**模板故事模式弹窗组件 */
const RenderTemplateStoryMode = ({ const RenderTemplateStoryMode = ({
@ -223,7 +108,7 @@ const RenderTemplateStoryMode = ({
// 模板列表渲染 // 模板列表渲染
const templateListRender = () => { const templateListRender = () => {
return ( 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> <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"> <div className="space-y-4 max-h-[700px] overflow-y-auto pr-3 template-list-scroll">
{templateStoryList.map((template, index) => ( {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 className="animate-spin rounded-full h-8 w-8 border-b-2 border-white"></div>
</div> </div>
) : selectedTemplate ? ( ) : 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 className="w-1/3">
<div <div
@ -277,14 +162,14 @@ const RenderTemplateStoryMode = ({
<div className="flex-1 flex flex-col"> <div className="flex-1 flex flex-col">
<h2 <h2
data-alt="template-title" data-alt="template-title"
className="text-2xl font-bold text-white mb-6" className="text-2xl font-bold text-white mb-4"
> >
{selectedTemplate.name} {selectedTemplate.name}
</h2> </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 <p
data-alt="template-description" data-alt="template-description"
className="text-gray-300 text-base leading-relaxed" className="text-gray-300 text-sm leading-relaxed"
> >
{selectedTemplate.generateText} {selectedTemplate.generateText}
</p> </p>
@ -293,7 +178,7 @@ const RenderTemplateStoryMode = ({
</div> </div>
{/* 角色自定义部分 - 精简布局 */} {/* 角色自定义部分 - 精简布局 */}
<div className="p-6"> <div className="p-4">
<h3 <h3
data-alt="roles-section-title" data-alt="roles-section-title"
className="text-lg font-semibold text-white mb-4" className="text-lg font-semibold text-white mb-4"
@ -409,7 +294,7 @@ const RenderTemplateStoryMode = ({
</div> </div>
{/* 弹窗底部操作 - 只保留 Action 按钮 */} {/* 弹窗底部操作 - 只保留 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="relative w-40 h-12 opacity-90 overflow-hidden rounded-xl bg-black z-10">
<div className="absolute z-10 -translate-x-32 group-hover:translate-x-[20rem] ease-in transistion-all duration-700 h-full w-32 bg-gradient-to-r from-gray-500 to-white/10 opacity-30 -skew-x-12"></div> <div className="absolute 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>
<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 className="absolute duration-1000 group-hover:animate-spin w-full h-[80px] bg-gradient-to-r from-[rgb(106,244,249)] to-[rgb(199,59,255)] blur-[30px]"></div>
</div> </div>
</div> */}
<div className=" absolute bottom-0 right-0">
<ActionButton
isCreating={localLoading}
handleCreateVideo={handleConfirm}
icon={<Clapperboard className="w-5 h-5" />}
/>
</div> </div>
</div> </div>
) : ( ) : (
@ -448,7 +340,6 @@ const RenderTemplateStoryMode = ({
}; };
return ( return (
<> <>
<style>{customAudioPlayerStyles}</style>
<Modal <Modal
open={isOpen} open={isOpen}
onCancel={() => { 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变化 * AI分析和故事生成功能UI变化
@ -1042,6 +772,7 @@ const PhotoStoryModal = ({
setCharactersAnalysis, setCharactersAnalysis,
originalUserDescription, originalUserDescription,
actionMovie, actionMovie,
uploadCharacterAvatarAndAnalyzeFeatures,
} = useImageStoryServiceHook(); } = useImageStoryServiceHook();
const { loadingText } = useLoadScriptText(isLoading); const { loadingText } = useLoadScriptText(isLoading);
const { uploadFile } = useUploadFile(); const { uploadFile } = useUploadFile();
@ -1222,47 +953,12 @@ const PhotoStoryModal = ({
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
// 创建隐藏的文件输入框 // 使用新的上传人物头像并分析特征方法
const input = document.createElement("input"); uploadCharacterAvatarAndAnalyzeFeatures(
input.type = "file"; avatar.name
input.accept = "image/*"; ).catch((error) => {
input.style.display = "none"; console.error("上传人物头像失败:", error);
});
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();
}} }}
className="absolute inset-0 bg-black/20 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center" 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"> <div className="flex items-start gap-4 mt-2 relative">
{/* 文本输入框 */} {/* 文本输入框 */}
<RoleHighlightEditor <HighlightEditor
content={storyContent} content={storyContent}
onContentChange={updateStoryContent} 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"> <div className="absolute bottom-1 right-0 flex gap-2">
{!hasAnalyzed ? ( {!hasAnalyzed ? (

View 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>
);
}

View 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>
);
};

View File

@ -7,7 +7,7 @@ import './style/create-to-video2.css';
import { createScriptEpisodeNew, getScriptEpisodeListNew, CreateScriptEpisodeRequest } from "@/api/script_episode"; import { createScriptEpisodeNew, getScriptEpisodeListNew, CreateScriptEpisodeRequest } from "@/api/script_episode";
import { EmptyStateAnimation } from '@/components/common/EmptyStateAnimation2'; import { EmptyStateAnimation } from '@/components/common/EmptyStateAnimation2';
import { ChatInputBox } from '@/components/common/ChatInputBox'; import { ChatInputBox } from '@/components/ChatInputBox/ChatInputBox';
// ideaText已迁移到ChatInputBox组件中 // ideaText已迁移到ChatInputBox组件中