forked from 77media/video-flow
太害怕了,差点代码就没了
This commit is contained in:
parent
cc973ba4ac
commit
188379c93c
33
api/movie_start.ts
Normal file
33
api/movie_start.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { ApiResponse } from "./common";
|
||||
import { get, post } from "./request";
|
||||
import {
|
||||
StoryTemplateEntity,
|
||||
ImageStoryEntity,
|
||||
} from "@/app/service/domain/Entities";
|
||||
|
||||
/**
|
||||
* 获取故事模板列表
|
||||
*/
|
||||
export const getTemplateStoryList = async () => {
|
||||
return await get<ApiResponse<StoryTemplateEntity[]>>("/template-story/list");
|
||||
};
|
||||
|
||||
/**
|
||||
* 执行故事模板操作,生成电影项目
|
||||
*/
|
||||
export const actionTemplateStory = async (template: StoryTemplateEntity) => {
|
||||
return await post<ApiResponse<{ projectId: string }>>(
|
||||
"/template-story/action",
|
||||
template
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* AI分析图片,生成分析结果
|
||||
*/
|
||||
export const AIGenerateImageStory = async (imageStory: ImageStoryEntity) => {
|
||||
return await post<ApiResponse<{ imageAnalysis: string; category: string }>>(
|
||||
"/image-story/ai-generate",
|
||||
imageStory
|
||||
);
|
||||
};
|
||||
160
app/service/Interaction/ImageStoryService.ts
Normal file
160
app/service/Interaction/ImageStoryService.ts
Normal file
@ -0,0 +1,160 @@
|
||||
import { ImageStoryEntity } from "../domain/Entities";
|
||||
import { ImageStoryUseCase } from "../usecase/imageStoryUseCase";
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
|
||||
interface UseImageStoryService {
|
||||
/** 当前图片故事数据 */
|
||||
imageStory: Partial<ImageStoryEntity>;
|
||||
/** 当前活跃的图片地址 */
|
||||
activeImageUrl: string;
|
||||
/** 当前活跃的文本信息 */
|
||||
activeTextContent: string;
|
||||
/** 当前选中的分类 */
|
||||
selectedCategory: string;
|
||||
/** 是否正在分析图片 */
|
||||
isAnalyzing: boolean;
|
||||
/** 是否正在上传 */
|
||||
isUploading: boolean;
|
||||
/** 故事类型选项 */
|
||||
storyTypeOptions: Array<{ key: string; label: string }>;
|
||||
/** 上传图片并分析 */
|
||||
uploadAndAnalyzeImage: (imageUrl: string) => Promise<void>;
|
||||
/** 触发生成剧本函数 */
|
||||
generateScript: () => Promise<string>;
|
||||
/** 更新故事类型 */
|
||||
updateStoryType: (storyType: string) => void;
|
||||
/** 更新故事内容 */
|
||||
updateStoryContent: (content: string) => void;
|
||||
/** 重置图片故事数据 */
|
||||
resetImageStory: () => void;
|
||||
}
|
||||
|
||||
export const useImageStoryServiceHook = (): UseImageStoryService => {
|
||||
const [imageStory, setImageStory] = useState<Partial<ImageStoryEntity>>({
|
||||
imageUrl: "",
|
||||
imageStory: "",
|
||||
storyType: "auto",
|
||||
});
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
/** 图片故事用例实例 */
|
||||
const imageStoryUseCase = useMemo(() => new ImageStoryUseCase(), []);
|
||||
|
||||
/** 当前活跃的图片地址 */
|
||||
const activeImageUrl = imageStory.imageUrl || "";
|
||||
|
||||
/** 当前活跃的文本信息 */
|
||||
const activeTextContent = imageStory.imageStory || "";
|
||||
|
||||
/** 当前选中的分类 */
|
||||
const selectedCategory = imageStory.storyType || "auto";
|
||||
|
||||
/** 故事类型选项 */
|
||||
const storyTypeOptions = useMemo(() => imageStoryUseCase.getStoryTypeOptions(), [imageStoryUseCase]);
|
||||
|
||||
/**
|
||||
* 上传图片并分析
|
||||
* @param {string} imageUrl - 已上传的图片URL
|
||||
*/
|
||||
const uploadAndAnalyzeImage = useCallback(async (imageUrl: string): Promise<void> => {
|
||||
try {
|
||||
setIsUploading(true);
|
||||
setIsAnalyzing(true);
|
||||
|
||||
// 调用用例处理图片上传和分析
|
||||
await imageStoryUseCase.handleImageUpload(imageUrl);
|
||||
|
||||
// 获取更新后的数据
|
||||
const updatedStory = imageStoryUseCase.getImageStory();
|
||||
setImageStory(updatedStory);
|
||||
|
||||
} catch (error) {
|
||||
console.error('图片上传分析失败:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
setIsAnalyzing(false);
|
||||
}
|
||||
}, [imageStoryUseCase]);
|
||||
|
||||
/**
|
||||
* 触发生成剧本函数
|
||||
* @returns {Promise<string>} 生成的剧本ID或内容
|
||||
*/
|
||||
const generateScript = useCallback(async (): Promise<string> => {
|
||||
if (!activeImageUrl) {
|
||||
throw new Error('请先上传图片');
|
||||
}
|
||||
|
||||
if (!activeTextContent) {
|
||||
throw new Error('请先输入或生成故事内容');
|
||||
}
|
||||
|
||||
try {
|
||||
setIsAnalyzing(true);
|
||||
|
||||
// 这里可以调用后端API生成剧本
|
||||
// 暂时返回一个模拟的剧本ID
|
||||
const scriptId = `script_${Date.now()}`;
|
||||
|
||||
// TODO: 实现实际的剧本生成逻辑
|
||||
// const response = await generateScriptFromImage(imageStory);
|
||||
// return response.scriptId;
|
||||
|
||||
return scriptId;
|
||||
} catch (error) {
|
||||
console.error('生成剧本失败:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsAnalyzing(false);
|
||||
}
|
||||
}, [activeImageUrl, activeTextContent, imageStory]);
|
||||
|
||||
/**
|
||||
* 更新故事类型
|
||||
* @param {string} storyType - 新的故事类型
|
||||
*/
|
||||
const updateStoryType = useCallback((storyType: string): void => {
|
||||
imageStoryUseCase.updateStoryType(storyType);
|
||||
setImageStory(prev => ({ ...prev, storyType }));
|
||||
}, [imageStoryUseCase]);
|
||||
|
||||
/**
|
||||
* 更新故事内容
|
||||
* @param {string} content - 新的故事内容
|
||||
*/
|
||||
const updateStoryContent = useCallback((content: string): void => {
|
||||
imageStoryUseCase.updateStoryContent(content);
|
||||
setImageStory(prev => ({ ...prev, imageStory: content }));
|
||||
}, [imageStoryUseCase]);
|
||||
|
||||
/**
|
||||
* 重置图片故事数据
|
||||
*/
|
||||
const resetImageStory = useCallback((): void => {
|
||||
imageStoryUseCase.resetImageStory();
|
||||
setImageStory({
|
||||
imageUrl: "",
|
||||
imageStory: "",
|
||||
storyType: "auto",
|
||||
});
|
||||
setIsAnalyzing(false);
|
||||
setIsUploading(false);
|
||||
}, [imageStoryUseCase]);
|
||||
|
||||
return {
|
||||
imageStory,
|
||||
activeImageUrl,
|
||||
activeTextContent,
|
||||
selectedCategory,
|
||||
isAnalyzing,
|
||||
isUploading,
|
||||
storyTypeOptions,
|
||||
uploadAndAnalyzeImage,
|
||||
generateScript,
|
||||
updateStoryType,
|
||||
updateStoryContent,
|
||||
resetImageStory,
|
||||
};
|
||||
};
|
||||
82
app/service/Interaction/templateStoryService.ts
Normal file
82
app/service/Interaction/templateStoryService.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import { StoryTemplateEntity, RoleEntity } from "../domain/Entities";
|
||||
import { TemplateStoryUseCase } from "../usecase/templateStoryUseCase";
|
||||
import { getUploadToken, uploadToQiniu } from "@/api/common";
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
|
||||
interface UseTemplateStoryService {
|
||||
/** 模板列表 */
|
||||
templateStoryList: StoryTemplateEntity[];
|
||||
/** 当前选中要使用的模板 */
|
||||
selectedTemplate: StoryTemplateEntity | null;
|
||||
/** 当前选中的活跃的角色 */
|
||||
activeRole: RoleEntity | null;
|
||||
/** 加载状态 */
|
||||
isLoading: boolean;
|
||||
/** 获取模板列表函数 */
|
||||
getTemplateStoryList: () => Promise<void>;
|
||||
/** action 生成电影函数 */
|
||||
actionStory: () => Promise<string>;
|
||||
/** 设置选中的模板 */
|
||||
setSelectedTemplate: (template: StoryTemplateEntity | null) => void;
|
||||
/** 设置活跃角色 */
|
||||
setActiveRole: (role: RoleEntity | null) => void;
|
||||
}
|
||||
|
||||
export const useTemplateStoryServiceHook = (): UseTemplateStoryService => {
|
||||
const [templateStoryList, setTemplateStoryList] = useState<StoryTemplateEntity[]>([]);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<StoryTemplateEntity | null>(null);
|
||||
const [activeRole, setActiveRole] = useState<RoleEntity | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
/** 模板故事用例实例 */
|
||||
const templateStoryUseCase = useMemo(() => new TemplateStoryUseCase(), []);
|
||||
|
||||
/**
|
||||
* 获取模板列表函数
|
||||
*/
|
||||
const getTemplateStoryList = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const templates = await templateStoryUseCase.getTemplateStoryList();
|
||||
setTemplateStoryList(templates);
|
||||
} catch (err) {
|
||||
console.error('获取模板列表失败:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [templateStoryUseCase]);
|
||||
|
||||
/**
|
||||
* action 生成电影函数
|
||||
*/
|
||||
const actionStory = useCallback(async (): Promise<string> => {
|
||||
if (!selectedTemplate) {
|
||||
throw new Error('请先选择一个故事模板');
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const projectId = await templateStoryUseCase.actionStory(selectedTemplate);
|
||||
return projectId;
|
||||
} catch (err) {
|
||||
console.error('生成电影失败:', err);
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [selectedTemplate, templateStoryUseCase]);
|
||||
|
||||
|
||||
return {
|
||||
templateStoryList,
|
||||
selectedTemplate,
|
||||
activeRole,
|
||||
isLoading,
|
||||
getTemplateStoryList,
|
||||
actionStory,
|
||||
setSelectedTemplate,
|
||||
setActiveRole,
|
||||
};
|
||||
};
|
||||
@ -90,12 +90,10 @@ export interface ImageStoryEntity {
|
||||
readonly id: string;
|
||||
/** 图片URL */
|
||||
imageUrl: string;
|
||||
/** 图片故事内容 */
|
||||
/** 图片故事用户描述 */
|
||||
imageStory: string;
|
||||
/** 图片故事剧本 */
|
||||
imageScript: string;
|
||||
/** 故事涉及的角色 */
|
||||
storyRole: RoleEntity[];
|
||||
/** 图片故事分析结果 */
|
||||
imageAnalysis: string;
|
||||
/** 故事分类 */
|
||||
storyType: string;
|
||||
}
|
||||
@ -109,14 +107,12 @@ export interface StoryTemplateEntity {
|
||||
/** 故事模板名称 */
|
||||
name: string;
|
||||
/** 故事模板图片 */
|
||||
imageUrl: string;
|
||||
/** 故事模板提示词 */
|
||||
imageUrl: string[];
|
||||
/** 故事模板概览*/
|
||||
generateText: string;
|
||||
/**故事角色 */
|
||||
storyRole: string[];
|
||||
/**用户自定义演绎资源 */
|
||||
userResources: {
|
||||
/**对应角色名 */
|
||||
storyRole: {
|
||||
/**角色名 */
|
||||
role_name: string;
|
||||
/**照片URL */
|
||||
photo_url: string;
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
|
||||
import { getUploadToken, uploadToQiniu } from "@/api/common";
|
||||
import { useState, useCallback } from "react";
|
||||
import { ScriptEditKey } from "../usecase/ScriptEditUseCase";
|
||||
/**
|
||||
* 渲染数据转换器
|
||||
@ -27,4 +29,37 @@ export function parseScriptBlock(
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 用于上传文件到七牛云的自定义 Hook
|
||||
* @returns {object} - 包含上传函数和加载状态
|
||||
*/
|
||||
export function useUploadFile() {
|
||||
/** 加载状态 */
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
/**
|
||||
* 上传文件到七牛云
|
||||
* @param {File} file - 要上传的文件
|
||||
* @param {(progress: number) => void} [onProgress] - 上传进度回调
|
||||
* @returns {Promise<string>} - 上传后文件的 URL
|
||||
* @throws {Error} - 上传失败时抛出异常
|
||||
*/
|
||||
const uploadFile = useCallback(
|
||||
async (file: File, onProgress?: (progress: number) => void): Promise<string> => {
|
||||
try {
|
||||
setIsUploading(true);
|
||||
const { token } = await getUploadToken();
|
||||
const fileUrl = await uploadToQiniu(file, token, onProgress);
|
||||
return fileUrl;
|
||||
} catch (err) {
|
||||
console.error('文件上传失败:', err);
|
||||
throw err;
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return { uploadFile, isUploading };
|
||||
}
|
||||
|
||||
153
app/service/usecase/imageStoryUseCase.ts
Normal file
153
app/service/usecase/imageStoryUseCase.ts
Normal file
@ -0,0 +1,153 @@
|
||||
import { ImageStoryEntity } from "../domain/Entities";
|
||||
import { AIGenerateImageStory } from "@/api/movie_start";
|
||||
|
||||
/**
|
||||
* 图片故事用例
|
||||
* 负责管理图片故事模式的业务逻辑,包括图片上传、AI分析和故事生成
|
||||
*/
|
||||
export class ImageStoryUseCase {
|
||||
/** 当前图片故事数据 */
|
||||
private imageStory: Partial<ImageStoryEntity> = {
|
||||
imageUrl: "",
|
||||
imageStory: "",
|
||||
storyType: "auto",
|
||||
};
|
||||
|
||||
/** 是否正在分析图片 */
|
||||
private isAnalyzing: boolean = false;
|
||||
|
||||
/** 是否正在上传 */
|
||||
private isUploading: boolean = false;
|
||||
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* 获取当前图片故事数据
|
||||
* @returns {Partial<ImageStoryEntity>} 图片故事数据
|
||||
*/
|
||||
getImageStory(): Partial<ImageStoryEntity> {
|
||||
return { ...this.imageStory };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分析状态
|
||||
* @returns {boolean} 是否正在分析
|
||||
*/
|
||||
getAnalyzingStatus(): boolean {
|
||||
return this.isAnalyzing;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取上传状态
|
||||
* @returns {boolean} 是否正在上传
|
||||
*/
|
||||
getUploadingStatus(): boolean {
|
||||
return this.isUploading;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置图片故事数据
|
||||
* @param {Partial<ImageStoryEntity>} data - 要设置的图片故事数据
|
||||
*/
|
||||
setImageStory(data: Partial<ImageStoryEntity>): void {
|
||||
this.imageStory = { ...this.imageStory, ...data };
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置图片故事数据
|
||||
*/
|
||||
resetImageStory(): void {
|
||||
this.imageStory = {
|
||||
imageUrl: "",
|
||||
imageStory: "",
|
||||
storyType: "auto",
|
||||
};
|
||||
this.isAnalyzing = false;
|
||||
this.isUploading = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理图片上传
|
||||
* @param {string} imageUrl - 已上传的图片URL
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async handleImageUpload(imageUrl: string): Promise<void> {
|
||||
try {
|
||||
this.isUploading = false; // 图片已上传,设置上传状态为false
|
||||
this.isAnalyzing = true;
|
||||
|
||||
// 设置上传后的图片URL
|
||||
this.setImageStory({ imageUrl });
|
||||
|
||||
// 调用AI分析接口
|
||||
await this.analyzeImageWithAI();
|
||||
|
||||
} catch (error) {
|
||||
console.error("图片分析失败:", error);
|
||||
// 分析失败时清空图片URL
|
||||
this.setImageStory({ imageUrl: "" });
|
||||
throw error;
|
||||
} finally {
|
||||
this.isAnalyzing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用AI分析图片
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
private async analyzeImageWithAI(): Promise<void> {
|
||||
try {
|
||||
// 调用AI分析接口
|
||||
const response = await AIGenerateImageStory(this.imageStory as ImageStoryEntity);
|
||||
|
||||
if (response.successful && response.data) {
|
||||
const { imageAnalysis, category } = response.data;
|
||||
|
||||
// 更新分析结果和分类
|
||||
this.setImageStory({
|
||||
imageAnalysis,
|
||||
storyType: category || "auto",
|
||||
imageStory: imageAnalysis, // 将AI分析结果作为默认故事内容
|
||||
});
|
||||
} else {
|
||||
throw new Error("AI分析失败");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("AI分析失败:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新故事类型
|
||||
* @param {string} storyType - 新的故事类型
|
||||
*/
|
||||
updateStoryType(storyType: string): void {
|
||||
this.setImageStory({ storyType });
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新故事内容
|
||||
* @param {string} storyContent - 新的故事内容
|
||||
*/
|
||||
updateStoryContent(storyContent: string): void {
|
||||
this.setImageStory({ imageStory: storyContent });
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取故事类型选项
|
||||
* @returns {Array<{key: string, label: string}>} 故事类型选项数组
|
||||
*/
|
||||
getStoryTypeOptions(): Array<{ key: string; label: string }> {
|
||||
return [
|
||||
{ key: "auto", label: "Auto" },
|
||||
{ key: "adventure", label: "Adventure" },
|
||||
{ key: "romance", label: "Romance" },
|
||||
{ key: "mystery", label: "Mystery" },
|
||||
{ key: "fantasy", label: "Fantasy" },
|
||||
{ key: "comedy", label: "Comedy" },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
56
app/service/usecase/templateStoryUseCase.ts
Normal file
56
app/service/usecase/templateStoryUseCase.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { StoryTemplateEntity } from "../domain/Entities";
|
||||
import { getTemplateStoryList, actionTemplateStory } from "@/api/movie_start";
|
||||
|
||||
/**
|
||||
* 模板故事用例
|
||||
* 负责管理故事模板的获取与操作
|
||||
*/
|
||||
export class TemplateStoryUseCase {
|
||||
/** 故事模板列表 */
|
||||
templateStoryList: StoryTemplateEntity[] = [];
|
||||
/** 当前选中的故事模板 */
|
||||
selectedTemplate: StoryTemplateEntity | null = null;
|
||||
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* 获取故事模板列表
|
||||
* @returns {Promise<StoryTemplateEntity[]>} - 故事模板实体数组
|
||||
*/
|
||||
async getTemplateStoryList(): Promise<StoryTemplateEntity[]> {
|
||||
try {
|
||||
const response = await getTemplateStoryList();
|
||||
if (response.successful && response.data) {
|
||||
this.templateStoryList = response.data;
|
||||
return response.data;
|
||||
}
|
||||
throw new Error(response.message || '获取故事模板列表失败');
|
||||
} catch (error) {
|
||||
console.error('获取故事模板列表失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行故事模板相关操作
|
||||
* @param {StoryTemplateEntity} template - 选中的故事模板
|
||||
* @returns {Promise<string>} - 项目id
|
||||
*/
|
||||
async actionStory(template: StoryTemplateEntity): Promise<string> {
|
||||
try {
|
||||
if (!template) {
|
||||
throw new Error('故事模板不能为空');
|
||||
}
|
||||
|
||||
const response = await actionTemplateStory(template);
|
||||
if (response.successful && response.data) {
|
||||
this.selectedTemplate = template;
|
||||
return response.data.projectId;
|
||||
}
|
||||
throw new Error(response.message || '执行故事模板操作失败');
|
||||
} catch (error) {
|
||||
console.error('执行故事模板操作失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
472
components/common/AudioRecorder.tsx
Normal file
472
components/common/AudioRecorder.tsx
Normal file
@ -0,0 +1,472 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { Mic, MicOff, Upload, Play, Pause, Trash2, X } from "lucide-react";
|
||||
import { Tooltip, Upload as AntdUpload } from "antd";
|
||||
import { InboxOutlined } from "@ant-design/icons";
|
||||
import WaveSurfer from "wavesurfer.js";
|
||||
|
||||
import { useUploadFile } from "../../app/service/domain/service";
|
||||
|
||||
// 自定义样式
|
||||
const audioRecorderStyles = `
|
||||
.slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.slider::-webkit-slider-track {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: #3b82f6;
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.slider::-webkit-slider-thumb:hover {
|
||||
background: #2563eb;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.slider::-moz-range-track {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.slider::-moz-range-thumb {
|
||||
background: #3b82f6;
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.slider::-moz-range-thumb:hover {
|
||||
background: #2563eb;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
`;
|
||||
|
||||
interface AudioRecorderProps {
|
||||
/** 当前音频URL */
|
||||
audioUrl?: string;
|
||||
/** 录制完成回调 */
|
||||
onAudioRecorded: (audioBlob: Blob, audioUrl: string) => void;
|
||||
/** 删除音频回调 */
|
||||
onAudioDeleted: () => void;
|
||||
/** 组件标题 */
|
||||
title?: string;
|
||||
/** 是否显示关闭按钮 */
|
||||
showCloseButton?: boolean;
|
||||
/** 关闭回调 */
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 音频录制组件,支持录制、上传和播放功能
|
||||
*/
|
||||
export function AudioRecorder({
|
||||
audioUrl,
|
||||
onAudioRecorded,
|
||||
onAudioDeleted,
|
||||
title = "请上传参考音频",
|
||||
showCloseButton = false,
|
||||
onClose,
|
||||
}: AudioRecorderProps) {
|
||||
const [mode, setMode] = useState<"upload" | "record">("upload"); // 当前模式:上传或录制
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [volume, setVolume] = useState(1);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const { uploadFile, isUploading } = useUploadFile();
|
||||
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
|
||||
// 开始录制
|
||||
const startRecording = async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const mediaRecorder = new MediaRecorder(stream);
|
||||
mediaRecorderRef.current = mediaRecorder;
|
||||
chunksRef.current = [];
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
chunksRef.current.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.onstop = () => {
|
||||
const audioBlob = new Blob(chunksRef.current, { type: "audio/wav" });
|
||||
const audioUrl = URL.createObjectURL(audioBlob);
|
||||
onAudioRecorded(audioBlob, audioUrl);
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
};
|
||||
|
||||
mediaRecorder.start();
|
||||
setIsRecording(true);
|
||||
} catch (error) {
|
||||
console.error("录制失败:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 停止录制
|
||||
const stopRecording = () => {
|
||||
if (mediaRecorderRef.current && isRecording) {
|
||||
mediaRecorderRef.current.stop();
|
||||
setIsRecording(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 播放/暂停控制
|
||||
const togglePlay = () => {
|
||||
setIsPlaying(!isPlaying);
|
||||
};
|
||||
|
||||
// 音量控制
|
||||
const toggleMute = () => {
|
||||
setIsMuted(!isMuted);
|
||||
};
|
||||
|
||||
// 音量调节
|
||||
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newVolume = parseFloat(e.target.value);
|
||||
setVolume(newVolume);
|
||||
if (isMuted && newVolume > 0) {
|
||||
setIsMuted(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 删除音频
|
||||
const handleDelete = () => {
|
||||
setIsPlaying(false);
|
||||
onAudioDeleted();
|
||||
};
|
||||
|
||||
// 渲染上传/录制状态
|
||||
if (!audioUrl) {
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* 主要内容区域 */}
|
||||
<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);
|
||||
|
||||
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>
|
||||
</div>
|
||||
) : (
|
||||
// 录制模式
|
||||
<div className="text-center w-full">
|
||||
{isRecording ? (
|
||||
// 录制中状态
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<button
|
||||
onClick={stopRecording}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors text-sm"
|
||||
>
|
||||
<MicOff className="w-3 h-3" />
|
||||
<span>Stop</span>
|
||||
</button>
|
||||
<div className="text-xs text-white/60">Recording...</div>
|
||||
</div>
|
||||
|
||||
{/* 录制状态指示器 */}
|
||||
<div className="w-full h-12 bg-white/[0.05] rounded-lg flex items-center justify-center">
|
||||
<div className="flex items-center gap-2 text-white/60">
|
||||
<div className="w-2 h-2 bg-red-500 rounded-full animate-pulse"></div>
|
||||
<span className="text-xs">Recording...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// 录制准备状态
|
||||
<div className="text-center">
|
||||
<div className="text-2xl text-white/40 mb-2">🎙️</div>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部模式切换图标 */}
|
||||
<div className="flex justify-center gap-6 mt-3 pt-3 border-t border-white/[0.1]">
|
||||
<Tooltip title="Switch to upload mode" placement="top">
|
||||
<button
|
||||
onClick={() => setMode("upload")}
|
||||
className={`transition-colors ${
|
||||
mode === "upload"
|
||||
? "text-blue-300"
|
||||
: "text-white/40 hover:text-white/60"
|
||||
}`}
|
||||
>
|
||||
<Upload className="w-5 h-5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Switch to recording mode" placement="top">
|
||||
<button
|
||||
onClick={() => setMode("record")}
|
||||
className={`transition-colors ${
|
||||
mode === "record"
|
||||
? "text-green-300"
|
||||
: "text-white/40 hover:text-white/60"
|
||||
}`}
|
||||
>
|
||||
<Mic className="w-5 h-5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// 渲染播放状态
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
|
||||
{/* WaveSurfer 波形图区域 */}
|
||||
<div className="mb-4">
|
||||
<div className="h-16 bg-white/[0.05] rounded-lg overflow-hidden">
|
||||
<WaveformPlayer
|
||||
audioUrl={audioUrl}
|
||||
isPlaying={isPlaying}
|
||||
onPlayStateChange={setIsPlaying}
|
||||
volume={volume}
|
||||
isMuted={isMuted}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 播放控制 */}
|
||||
<div className="flex items-center justify-center gap-4 mb-4">
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
className="flex items-center justify-center w-12 h-12 bg-blue-500 text-white rounded-full hover:bg-blue-600 transition-colors"
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="w-5 h-5" />
|
||||
) : (
|
||||
<Play className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 音频设置 */}
|
||||
<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>
|
||||
|
||||
<div className="text-xs text-white/40">1x</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface WaveformPlayerProps {
|
||||
audioUrl: string;
|
||||
isPlaying: boolean;
|
||||
onPlayStateChange: (isPlaying: boolean) => void;
|
||||
volume?: number;
|
||||
isMuted?: boolean;
|
||||
}
|
||||
|
||||
function WaveformPlayer({
|
||||
audioUrl,
|
||||
isPlaying,
|
||||
onPlayStateChange,
|
||||
volume = 1,
|
||||
isMuted = false,
|
||||
}: WaveformPlayerProps) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const wavesurferRef = useRef<WaveSurfer | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !audioUrl) return;
|
||||
|
||||
const ws = WaveSurfer.create({
|
||||
container: containerRef.current,
|
||||
waveColor: "#3b82f6",
|
||||
progressColor: "#1d4ed8",
|
||||
cursorColor: "#1e40af",
|
||||
height: 64,
|
||||
barWidth: 2,
|
||||
barGap: 1,
|
||||
normalize: true,
|
||||
url: audioUrl,
|
||||
});
|
||||
|
||||
// 监听播放状态变化
|
||||
ws.on("play", () => onPlayStateChange(true));
|
||||
ws.on("pause", () => onPlayStateChange(false));
|
||||
ws.on("finish", () => onPlayStateChange(false));
|
||||
|
||||
wavesurferRef.current = ws;
|
||||
|
||||
return () => {
|
||||
ws.destroy();
|
||||
};
|
||||
}, [audioUrl, onPlayStateChange]);
|
||||
|
||||
// 同步外部播放状态和音量
|
||||
useEffect(() => {
|
||||
if (!wavesurferRef.current) return;
|
||||
|
||||
if (isPlaying && !wavesurferRef.current.isPlaying()) {
|
||||
wavesurferRef.current.play();
|
||||
} else if (!isPlaying && wavesurferRef.current.isPlaying()) {
|
||||
wavesurferRef.current.pause();
|
||||
}
|
||||
|
||||
// 设置音量
|
||||
const currentVolume = isMuted ? 0 : volume;
|
||||
wavesurferRef.current.setVolume(currentVolume);
|
||||
}, [isPlaying, volume, isMuted]);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<div ref={containerRef} className="w-full h-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
100
components/common/PhotoStoryMode.tsx
Normal file
100
components/common/PhotoStoryMode.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2, Trash2, ChevronDown } from "lucide-react";
|
||||
import { Dropdown, Image } from "antd";
|
||||
import { EyeOutlined } from "@ant-design/icons";
|
||||
import { useImageStoryServiceHook } from "@/app/service/Interaction/ImageStoryService";
|
||||
|
||||
/**
|
||||
* 图片故事模式组件
|
||||
* 显示图片预览、分析状态和故事类型选择器
|
||||
* 使用ImageStoryService hook管理状态和业务逻辑
|
||||
*/
|
||||
export function PhotoStoryMode() {
|
||||
// 使用图片故事服务hook
|
||||
const {
|
||||
activeImageUrl,
|
||||
selectedCategory,
|
||||
isAnalyzing,
|
||||
isUploading,
|
||||
storyTypeOptions,
|
||||
updateStoryType,
|
||||
resetImageStory,
|
||||
} = useImageStoryServiceHook();
|
||||
|
||||
return (
|
||||
<div className="absolute top-[-110px] left-14 right-0 flex items-center justify-between">
|
||||
{/* 左侧:图片预览区域和分析状态指示器 */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* 图片预览区域 - 使用Ant Design Image组件 */}
|
||||
<div className="relative w-24 h-24 rounded-lg overflow-hidden bg-white/[0.05] border border-white/[0.1] shadow-[0_4px_16px_rgba(0,0,0,0.2)]">
|
||||
{activeImageUrl && (
|
||||
<Image
|
||||
src={activeImageUrl}
|
||||
alt="Story inspiration"
|
||||
className="w-full h-full object-cover"
|
||||
preview={{
|
||||
mask: (
|
||||
<EyeOutlined className="w-6 h-6 text-white/80" />
|
||||
),
|
||||
maskClassName:
|
||||
"flex items-center justify-center bg-black/50 hover:bg-black/70 transition-colors",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 删除图片按钮 - 简洁样式 */}
|
||||
{activeImageUrl && (
|
||||
<button
|
||||
onClick={resetImageStory}
|
||||
className="absolute -top-2 left-24 w-6 h-6 bg-black/60 hover:bg-black/80 rounded-full flex items-center justify-center text-white/80 hover:text-white transition-all duration-200 z-10"
|
||||
title="删除图片并退出图片故事模式"
|
||||
>
|
||||
<Trash2 className="w-2.5 h-2.5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 分析状态指示器 */}
|
||||
{isAnalyzing && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-white/[0.1] rounded-lg">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-white/80" />
|
||||
<span className="text-sm text-white/80">
|
||||
{isUploading
|
||||
? "Uploading image..."
|
||||
: "Analyzing image..."}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右侧:故事类型选择器 */}
|
||||
{activeImageUrl && (
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: storyTypeOptions.map((type) => ({
|
||||
key: type.key,
|
||||
label: (
|
||||
<div className="px-3 py-2 text-sm text-white/90">
|
||||
{type.label}
|
||||
</div>
|
||||
),
|
||||
})),
|
||||
onClick: ({ key }) => updateStoryType(key),
|
||||
}}
|
||||
trigger={["click"]}
|
||||
placement="bottomRight"
|
||||
>
|
||||
<button className="px-3 py-2 bg-white/[0.1] hover:bg-white/[0.15] border border-white/[0.2] rounded-lg text-white/80 text-sm transition-colors flex items-center gap-2">
|
||||
<span>
|
||||
{storyTypeOptions.find(
|
||||
(t) => t.key === selectedCategory
|
||||
)?.label || "Auto"}
|
||||
</span>
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
</button>
|
||||
</Dropdown>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
264
components/common/templateCard.tsx
Normal file
264
components/common/templateCard.tsx
Normal file
@ -0,0 +1,264 @@
|
||||
import React from "react";
|
||||
|
||||
interface TemplateCardProps {
|
||||
/** 图片URL */
|
||||
imageUrl: string;
|
||||
/** 图片alt文本 */
|
||||
imageAlt?: string;
|
||||
/** 标题 */
|
||||
title: string;
|
||||
/** 描述文字 */
|
||||
description: string;
|
||||
/** 是否选中,默认false */
|
||||
isSelected?: boolean;
|
||||
/** 卡片宽度,默认150px */
|
||||
width?: number;
|
||||
/** 卡片高度,默认200px */
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 3D翻转卡片组件
|
||||
* 正面显示图片,背面显示标题和描述
|
||||
*/
|
||||
const TemplateCard: React.FC<TemplateCardProps> = ({
|
||||
imageUrl,
|
||||
imageAlt = "",
|
||||
title,
|
||||
description,
|
||||
isSelected = false,
|
||||
width = 150,
|
||||
height = 200,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`card ${isSelected ? "selected" : ""}`}
|
||||
style={{ width: `${width}px`, height: `${height}px` }}
|
||||
data-alt="template-card"
|
||||
>
|
||||
<div className="card-container">
|
||||
{/* 背面 - 显示图片 */}
|
||||
<div className="card-back">
|
||||
<div className="back-image-wrapper">
|
||||
<img src={imageUrl} alt={imageAlt} className="back-image" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 正面 - 显示文字和流光效果 */}
|
||||
<div className="card-front">
|
||||
<div className="floating-circle floating-circle-1"></div>
|
||||
<div className="floating-circle floating-circle-2"></div>
|
||||
<div className="floating-circle floating-circle-3"></div>
|
||||
<div className="front-content-overlay">
|
||||
<div className="free-badge">Free</div>
|
||||
<div className="text-content">
|
||||
<h3 className="card-title">{title}</h3>
|
||||
<p className="card-description">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
.card {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.card-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform-style: preserve-3d;
|
||||
transition: transform 300ms;
|
||||
box-shadow: 0px 0px 10px 1px #000000ee;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card-front,
|
||||
.card-back {
|
||||
background-color: #151515;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-back {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-back::before {
|
||||
position: absolute;
|
||||
content: " ";
|
||||
display: block;
|
||||
width: 160px;
|
||||
height: 160%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgb(106, 244, 249),
|
||||
rgb(199, 59, 255),
|
||||
rgb(106, 244, 249),
|
||||
rgb(199, 59, 255),
|
||||
transparent
|
||||
);
|
||||
animation: rotation_481 5000ms infinite linear;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.card.selected .card-back::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.back-image-wrapper {
|
||||
position: absolute;
|
||||
width: 99%;
|
||||
height: 99%;
|
||||
background-color: #151515;
|
||||
border-radius: 5px;
|
||||
color: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: 0px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.free-badge {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgb(106, 244, 249),
|
||||
rgb(199, 59, 255)
|
||||
);
|
||||
color: white;
|
||||
padding: 3px 10px;
|
||||
border-radius: 10px;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.text-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.card-description {
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
color: #cccccc;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.card:hover .card-container {
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
|
||||
@keyframes rotation_481 {
|
||||
0% {
|
||||
transform: rotateZ(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotateZ(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.card-front {
|
||||
transform: rotateY(180deg);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.front-content-overlay {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(21, 21, 21, 0.6);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 5px;
|
||||
color: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: 1rem;
|
||||
box-sizing: border-box;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.back-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
.floating-circle {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
background-color: #ffbb66;
|
||||
position: absolute;
|
||||
filter: blur(12px);
|
||||
animation: floating 2600ms infinite linear;
|
||||
}
|
||||
|
||||
.floating-circle-2 {
|
||||
background-color: #ff8866;
|
||||
left: 30px;
|
||||
top: 70px;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
animation-delay: -800ms;
|
||||
}
|
||||
|
||||
.floating-circle-3 {
|
||||
background-color: #ff2233;
|
||||
left: 100px;
|
||||
top: 30px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
animation-delay: -1800ms;
|
||||
}
|
||||
|
||||
@keyframes floating {
|
||||
0% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(10px);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TemplateCard;
|
||||
@ -3,14 +3,9 @@
|
||||
import { useState, useEffect, useRef, useCallback } from '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';
|
||||
import { Dropdown, Menu } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import dynamic from 'next/dynamic';
|
||||
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';
|
||||
|
||||
|
||||
56
package-lock.json
generated
56
package-lock.json
generated
@ -88,7 +88,6 @@
|
||||
"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",
|
||||
@ -98,6 +97,7 @@
|
||||
"react-resizable-panels": "^2.1.3",
|
||||
"react-rough-notation": "^1.0.5",
|
||||
"react-textarea-autosize": "^8.5.9",
|
||||
"react-wavesurfer.js": "^0.0.8",
|
||||
"recharts": "^2.15.4",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^1.5.0",
|
||||
@ -109,7 +109,7 @@
|
||||
"three": "^0.177.0",
|
||||
"typescript": "5.2.2",
|
||||
"vaul": "^0.9.9",
|
||||
"wavesurfer.js": "^7.9.9",
|
||||
"wavesurfer.js": "^7.10.1",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -1020,27 +1020,6 @@
|
||||
"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",
|
||||
@ -17829,20 +17808,6 @@
|
||||
"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",
|
||||
@ -18137,6 +18102,17 @@
|
||||
"react-dom": ">=16.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-wavesurfer.js": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/react-wavesurfer.js/-/react-wavesurfer.js-0.0.8.tgz",
|
||||
"integrity": "sha512-73UDCIbHolcKsT8mVKiMxsgBwNvjOtq1eVisLV/d7W7+1W/y0cpBsEfSHfEIS63au9bXpepC+pGiN5kepWrUuw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": ">=17.0.2",
|
||||
"react-dom": ">=17.0.2",
|
||||
"wavesurfer.js": "5.x.x || 6.x.x"
|
||||
}
|
||||
},
|
||||
"node_modules/read-cache": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||
@ -20447,9 +20423,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wavesurfer.js": {
|
||||
"version": "7.9.9",
|
||||
"resolved": "https://registry.npmmirror.com/wavesurfer.js/-/wavesurfer.js-7.9.9.tgz",
|
||||
"integrity": "sha512-8O/zu+RC7yjikxiuhsXzRZ8vvjV+Qq4PUKZBQsLLcq6fqbrSF3Vh99l7fT8zeEjKjDBNH2Qxsxq5mRJIuBmM3Q==",
|
||||
"version": "7.10.1",
|
||||
"resolved": "https://registry.npmjs.org/wavesurfer.js/-/wavesurfer.js-7.10.1.tgz",
|
||||
"integrity": "sha512-tF1ptFCAi8SAqKbM1e7705zouLC3z4ulXCg15kSP5dQ7VDV30Q3x/xFRcuVIYTT5+jB/PdkhiBRCfsMshZG1Ug==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
|
||||
@ -91,7 +91,6 @@
|
||||
"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",
|
||||
@ -101,6 +100,7 @@
|
||||
"react-resizable-panels": "^2.1.3",
|
||||
"react-rough-notation": "^1.0.5",
|
||||
"react-textarea-autosize": "^8.5.9",
|
||||
"react-wavesurfer.js": "^0.0.8",
|
||||
"recharts": "^2.15.4",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^1.5.0",
|
||||
@ -112,7 +112,7 @@
|
||||
"three": "^0.177.0",
|
||||
"typescript": "5.2.2",
|
||||
"vaul": "^0.9.9",
|
||||
"wavesurfer.js": "^7.9.9",
|
||||
"wavesurfer.js": "^7.10.1",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user