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;
|
readonly id: string;
|
||||||
/** 图片URL */
|
/** 图片URL */
|
||||||
imageUrl: string;
|
imageUrl: string;
|
||||||
/** 图片故事内容 */
|
/** 图片故事用户描述 */
|
||||||
imageStory: string;
|
imageStory: string;
|
||||||
/** 图片故事剧本 */
|
/** 图片故事分析结果 */
|
||||||
imageScript: string;
|
imageAnalysis: string;
|
||||||
/** 故事涉及的角色 */
|
|
||||||
storyRole: RoleEntity[];
|
|
||||||
/** 故事分类 */
|
/** 故事分类 */
|
||||||
storyType: string;
|
storyType: string;
|
||||||
}
|
}
|
||||||
@ -109,14 +107,12 @@ export interface StoryTemplateEntity {
|
|||||||
/** 故事模板名称 */
|
/** 故事模板名称 */
|
||||||
name: string;
|
name: string;
|
||||||
/** 故事模板图片 */
|
/** 故事模板图片 */
|
||||||
imageUrl: string;
|
imageUrl: string[];
|
||||||
/** 故事模板提示词 */
|
/** 故事模板概览*/
|
||||||
generateText: string;
|
generateText: string;
|
||||||
/**故事角色 */
|
/**故事角色 */
|
||||||
storyRole: string[];
|
storyRole: {
|
||||||
/**用户自定义演绎资源 */
|
/**角色名 */
|
||||||
userResources: {
|
|
||||||
/**对应角色名 */
|
|
||||||
role_name: string;
|
role_name: string;
|
||||||
/**照片URL */
|
/**照片URL */
|
||||||
photo_url: string;
|
photo_url: string;
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
|
|
||||||
|
import { getUploadToken, uploadToQiniu } from "@/api/common";
|
||||||
|
import { useState, useCallback } from "react";
|
||||||
import { ScriptEditKey } from "../usecase/ScriptEditUseCase";
|
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 { 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 { 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 { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import './style/create-to-video2.css';
|
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 { createScriptEpisodeNew, getScriptEpisodeListNew, CreateScriptEpisodeRequest } from "@/api/script_episode";
|
||||||
import { getUploadToken, uploadToQiniu } from "@/api/common";
|
|
||||||
import { EmptyStateAnimation } from '@/components/common/EmptyStateAnimation2';
|
import { EmptyStateAnimation } from '@/components/common/EmptyStateAnimation2';
|
||||||
import { ChatInputBox } from '@/components/common/ChatInputBox';
|
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-day-picker": "^8.10.1",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-grid-layout": "^1.5.1",
|
"react-grid-layout": "^1.5.1",
|
||||||
"react-h5-audio-player": "^3.10.0",
|
|
||||||
"react-hook-form": "^7.53.0",
|
"react-hook-form": "^7.53.0",
|
||||||
"react-intersection-observer": "^9.16.0",
|
"react-intersection-observer": "^9.16.0",
|
||||||
"react-joyride": "^2.9.3",
|
"react-joyride": "^2.9.3",
|
||||||
@ -98,6 +97,7 @@
|
|||||||
"react-resizable-panels": "^2.1.3",
|
"react-resizable-panels": "^2.1.3",
|
||||||
"react-rough-notation": "^1.0.5",
|
"react-rough-notation": "^1.0.5",
|
||||||
"react-textarea-autosize": "^8.5.9",
|
"react-textarea-autosize": "^8.5.9",
|
||||||
|
"react-wavesurfer.js": "^0.0.8",
|
||||||
"recharts": "^2.15.4",
|
"recharts": "^2.15.4",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"sonner": "^1.5.0",
|
"sonner": "^1.5.0",
|
||||||
@ -109,7 +109,7 @@
|
|||||||
"three": "^0.177.0",
|
"three": "^0.177.0",
|
||||||
"typescript": "5.2.2",
|
"typescript": "5.2.2",
|
||||||
"vaul": "^0.9.9",
|
"vaul": "^0.9.9",
|
||||||
"wavesurfer.js": "^7.9.9",
|
"wavesurfer.js": "^7.10.1",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -1020,27 +1020,6 @@
|
|||||||
"integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==",
|
"integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==",
|
||||||
"license": "BSD-3-Clause"
|
"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": {
|
"node_modules/@isaacs/cliui": {
|
||||||
"version": "8.0.2",
|
"version": "8.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||||
@ -17829,20 +17808,6 @@
|
|||||||
"react-dom": ">= 16.3.0"
|
"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": {
|
"node_modules/react-hook-form": {
|
||||||
"version": "7.59.0",
|
"version": "7.59.0",
|
||||||
"resolved": "https://registry.npmmirror.com/react-hook-form/-/react-hook-form-7.59.0.tgz",
|
"resolved": "https://registry.npmmirror.com/react-hook-form/-/react-hook-form-7.59.0.tgz",
|
||||||
@ -18137,6 +18102,17 @@
|
|||||||
"react-dom": ">=16.6.0"
|
"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": {
|
"node_modules/read-cache": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||||
@ -20447,9 +20423,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/wavesurfer.js": {
|
"node_modules/wavesurfer.js": {
|
||||||
"version": "7.9.9",
|
"version": "7.10.1",
|
||||||
"resolved": "https://registry.npmmirror.com/wavesurfer.js/-/wavesurfer.js-7.9.9.tgz",
|
"resolved": "https://registry.npmjs.org/wavesurfer.js/-/wavesurfer.js-7.10.1.tgz",
|
||||||
"integrity": "sha512-8O/zu+RC7yjikxiuhsXzRZ8vvjV+Qq4PUKZBQsLLcq6fqbrSF3Vh99l7fT8zeEjKjDBNH2Qxsxq5mRJIuBmM3Q==",
|
"integrity": "sha512-tF1ptFCAi8SAqKbM1e7705zouLC3z4ulXCg15kSP5dQ7VDV30Q3x/xFRcuVIYTT5+jB/PdkhiBRCfsMshZG1Ug==",
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
"node_modules/webidl-conversions": {
|
"node_modules/webidl-conversions": {
|
||||||
|
|||||||
@ -91,7 +91,6 @@
|
|||||||
"react-day-picker": "^8.10.1",
|
"react-day-picker": "^8.10.1",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-grid-layout": "^1.5.1",
|
"react-grid-layout": "^1.5.1",
|
||||||
"react-h5-audio-player": "^3.10.0",
|
|
||||||
"react-hook-form": "^7.53.0",
|
"react-hook-form": "^7.53.0",
|
||||||
"react-intersection-observer": "^9.16.0",
|
"react-intersection-observer": "^9.16.0",
|
||||||
"react-joyride": "^2.9.3",
|
"react-joyride": "^2.9.3",
|
||||||
@ -101,6 +100,7 @@
|
|||||||
"react-resizable-panels": "^2.1.3",
|
"react-resizable-panels": "^2.1.3",
|
||||||
"react-rough-notation": "^1.0.5",
|
"react-rough-notation": "^1.0.5",
|
||||||
"react-textarea-autosize": "^8.5.9",
|
"react-textarea-autosize": "^8.5.9",
|
||||||
|
"react-wavesurfer.js": "^0.0.8",
|
||||||
"recharts": "^2.15.4",
|
"recharts": "^2.15.4",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"sonner": "^1.5.0",
|
"sonner": "^1.5.0",
|
||||||
@ -112,7 +112,7 @@
|
|||||||
"three": "^0.177.0",
|
"three": "^0.177.0",
|
||||||
"typescript": "5.2.2",
|
"typescript": "5.2.2",
|
||||||
"vaul": "^0.9.9",
|
"vaul": "^0.9.9",
|
||||||
"wavesurfer.js": "^7.9.9",
|
"wavesurfer.js": "^7.10.1",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user