forked from 77media/video-flow
一个阶段
This commit is contained in:
parent
09fc676a35
commit
76169e790d
43
api/DTO/movie_start_dto.ts
Normal file
43
api/DTO/movie_start_dto.ts
Normal file
@ -0,0 +1,43 @@
|
||||
/**
|
||||
* 图片故事AI分析返回DTO
|
||||
*/
|
||||
export interface CharacterRegion {
|
||||
/** x坐标 */
|
||||
x: number;
|
||||
/** y坐标 */
|
||||
y: number;
|
||||
/** 区域宽度 */
|
||||
width: number;
|
||||
/** 区域高度 */
|
||||
height: number;
|
||||
}
|
||||
|
||||
/** 角色分析信息 */
|
||||
export interface CharacterAnalysis {
|
||||
/** 角色ID */
|
||||
id: string;
|
||||
/** 角色名称 */
|
||||
role_name: string;
|
||||
/** 角色区域 */
|
||||
region: CharacterRegion;
|
||||
/** 角色头像URL(可选,用于存储裁剪后的头像) */
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
/** 图片故事AI分析返回结构 */
|
||||
export interface MovieStartDTO {
|
||||
/** 是否成功 */
|
||||
success: boolean;
|
||||
/** 故事梗概 */
|
||||
story_logline: string;
|
||||
/** 分类数据 */
|
||||
potential_genres: string[];
|
||||
/** 角色头像及名称 */
|
||||
characters_analysis: CharacterAnalysis[];
|
||||
/** 图片URL */
|
||||
image_url: string;
|
||||
/** 用户输入文本 */
|
||||
user_text: string;
|
||||
/** 错误信息 */
|
||||
error: string | null;
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
import { ApiResponse } from "./common";
|
||||
import { MovieStartDTO } from "./DTO/movie_start_dto";
|
||||
import { get, post } from "./request";
|
||||
import {
|
||||
StoryTemplateEntity,
|
||||
@ -25,9 +26,12 @@ export const actionTemplateStory = async (template: StoryTemplateEntity) => {
|
||||
/**
|
||||
* AI分析图片,生成分析结果
|
||||
*/
|
||||
export const AIGenerateImageStory = async (imageStory: ImageStoryEntity) => {
|
||||
return await post<ApiResponse<{ imageAnalysis: string; category: string }>>(
|
||||
"/image-story/ai-generate",
|
||||
imageStory
|
||||
export const AIGenerateImageStory = async (request: {
|
||||
imageUrl: string;
|
||||
user_text: string;
|
||||
}) => {
|
||||
return await post<ApiResponse<MovieStartDTO>>(
|
||||
"/movie_story/generate",
|
||||
request
|
||||
);
|
||||
};
|
||||
|
||||
@ -2,22 +2,27 @@ import { ImageStoryEntity } from "../domain/Entities";
|
||||
import { useUploadFile } from "../domain/service";
|
||||
import { ImageStoryUseCase } from "../usecase/imageStoryUseCase";
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { CharacterAnalysis } from "@/api/DTO/movie_start_dto";
|
||||
|
||||
interface UseImageStoryService {
|
||||
/** 当前图片故事数据 */
|
||||
imageStory: Partial<ImageStoryEntity>;
|
||||
/** 当前活跃的图片地址 */
|
||||
activeImageUrl: string;
|
||||
/** 分析故事结果内容 */
|
||||
analyzedStoryContent: string;
|
||||
/** 故事内容(用户输入或AI分析结果) */
|
||||
storyContent: string;
|
||||
/** 角色头像及名称数据 */
|
||||
charactersAnalysis: CharacterAnalysis[];
|
||||
/** 分类数据 */
|
||||
potentialGenres: string[];
|
||||
/** 当前选中的分类 */
|
||||
selectedCategory: string;
|
||||
/** 是否正在分析图片 */
|
||||
isAnalyzing: boolean;
|
||||
/** 是否正在上传 */
|
||||
isUploading: boolean;
|
||||
/** 故事类型选项 */
|
||||
storyTypeOptions: Array<{ key: string; label: string }>;
|
||||
/** 是否正在加载中(上传或分析) */
|
||||
isLoading: boolean;
|
||||
/** 是否已经分析过图片 */
|
||||
hasAnalyzed: boolean;
|
||||
/** 计算后的角色头像数据 */
|
||||
avatarComputed: Array<{ name: string; url: string }>;
|
||||
/** 上传图片并分析 */
|
||||
uploadAndAnalyzeImage: (imageUrl: string) => Promise<void>;
|
||||
/** 触发文件选择并自动分析 */
|
||||
@ -28,68 +33,242 @@ interface UseImageStoryService {
|
||||
updateStoryType: (storyType: string) => void;
|
||||
/** 更新故事内容 */
|
||||
updateStoryContent: (content: string) => void;
|
||||
/** 更新角色名称并同步到相关数据 */
|
||||
updateCharacterName: (oldName: string, newName: string) => void;
|
||||
/** 重置图片故事数据 */
|
||||
resetImageStory: () => void;
|
||||
/** 完全重置到初始状态(包括预置数据) */
|
||||
resetToInitialState: () => void;
|
||||
}
|
||||
|
||||
export const useImageStoryServiceHook = (
|
||||
): UseImageStoryService => {
|
||||
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 [activeImageUrl, setActiveImageUrl] = useState<string>("https://cdn.qikongjian.com/1755316261_gmw4yq.mp4?vframe/jpg/offset/1");
|
||||
|
||||
// 故事内容状态(统一管理用户输入和AI分析结果),预置假数据
|
||||
const [storyContent, setStoryContent] = useState<string>(
|
||||
"在一个遥远的未来城市,机器人与人类共存,但一场突如其来的危机打破了平衡。艾米丽,阿尔法,博士互相帮助,共同解决危机。"
|
||||
);
|
||||
|
||||
// 分析结果状态
|
||||
/** 角色头像及名称,预置假数据 */
|
||||
const [charactersAnalysis, setCharactersAnalysis] = useState<
|
||||
CharacterAnalysis[]
|
||||
>([
|
||||
{
|
||||
role_name: "艾米丽",
|
||||
region: {
|
||||
x: 0.2,
|
||||
y: 0.2,
|
||||
width: 0.2,
|
||||
height: 0.2,
|
||||
},
|
||||
id: "1"
|
||||
},
|
||||
{
|
||||
role_name: "阿尔法",
|
||||
region: {
|
||||
x: 0.4,
|
||||
y: 0.4,
|
||||
width: 0.2,
|
||||
height: 0.2,
|
||||
},
|
||||
id: "2"
|
||||
},
|
||||
{
|
||||
role_name: "博士",
|
||||
region: {
|
||||
x: 0.6,
|
||||
y: 0.6,
|
||||
width: 0.2,
|
||||
height: 0.2,
|
||||
},
|
||||
id: "3"
|
||||
},
|
||||
]);
|
||||
/** 分类数组,预置假数据 */
|
||||
const [potentialGenres, setPotentialGenres] = useState<string[]>([
|
||||
"科幻",
|
||||
"冒险",
|
||||
"悬疑",
|
||||
]);
|
||||
|
||||
// 分类状态
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>("Auto");
|
||||
|
||||
// 流程状态
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [hasAnalyzed, setHasAnalyzed] = useState(true);
|
||||
|
||||
// 使用上传文件Hook
|
||||
const { uploadFile } = useUploadFile();
|
||||
|
||||
/** 图片故事用例实例 */
|
||||
const imageStoryUseCase = useMemo(() => new ImageStoryUseCase(), []);
|
||||
|
||||
/** 当前活跃的图片地址 */
|
||||
const [activeImageUrl, setActiveImageUrl] = useState<string>("");
|
||||
/**
|
||||
* 根据角色区域信息生成头像URL
|
||||
* @param character - 角色信息
|
||||
* @param imageUrl - 源图片URL
|
||||
*/
|
||||
const generateAvatarFromRegion = useCallback((character: CharacterAnalysis, imageUrl: string) => {
|
||||
// 创建图片对象
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous'; // 处理跨域问题
|
||||
|
||||
/** 分析故事结果内容 */
|
||||
const [analyzedStoryContent, setAnalyzedStoryContent] = useState<string>("");
|
||||
img.onload = () => {
|
||||
try {
|
||||
// 根据百分比计算实际的像素坐标
|
||||
const cropX = Math.round(character.region.x * img.width);
|
||||
const cropY = Math.round(character.region.y * img.height);
|
||||
const cropWidth = Math.round(character.region.width * img.width);
|
||||
const cropHeight = Math.round(character.region.height * img.height);
|
||||
console.log(cropX, cropY, cropWidth, cropHeight);
|
||||
|
||||
/** 当前选中的分类 */
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>("auto");
|
||||
|
||||
/** 故事类型选项 */
|
||||
const storyTypeOptions = useMemo(() => imageStoryUseCase.getStoryTypeOptions(), [imageStoryUseCase]);
|
||||
// 验证裁剪区域是否有效
|
||||
if (cropWidth <= 0 || cropHeight <= 0) {
|
||||
console.error('裁剪区域无效:', { cropWidth, cropHeight });
|
||||
return;
|
||||
}
|
||||
|
||||
if (cropX + cropWidth > img.width || cropY + cropHeight > img.height) {
|
||||
console.error('裁剪区域超出图片边界');
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建canvas元素用于图片裁剪
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (!ctx) {
|
||||
console.error('无法创建canvas上下文');
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置canvas尺寸为裁剪后的尺寸
|
||||
canvas.width = cropWidth;
|
||||
canvas.height = cropHeight;
|
||||
|
||||
// 清除canvas内容
|
||||
ctx.clearRect(0, 0, cropWidth, cropHeight);
|
||||
|
||||
// 在canvas上绘制裁剪后的图片部分
|
||||
ctx.drawImage(
|
||||
img,
|
||||
cropX, cropY, cropWidth, cropHeight, // 源图片裁剪区域
|
||||
0, 0, cropWidth, cropHeight // 目标canvas区域
|
||||
);
|
||||
|
||||
// 将canvas转换为blob并创建临时URL
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
console.log('成功生成头像URL:', url, '大小:', blob.size);
|
||||
|
||||
// 更新角色头像URL
|
||||
setCharactersAnalysis(prev =>
|
||||
prev.map(char =>
|
||||
char.role_name === character.role_name
|
||||
? { ...char, avatarUrl: url }
|
||||
: char
|
||||
)
|
||||
);
|
||||
} else {
|
||||
console.error('Canvas转Blob失败');
|
||||
}
|
||||
}, 'image/jpeg', 0.9);
|
||||
|
||||
// 清理canvas
|
||||
canvas.remove();
|
||||
} catch (error) {
|
||||
console.error('生成角色头像失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
console.error('加载图片失败:', imageUrl);
|
||||
};
|
||||
|
||||
// 开始加载图片
|
||||
img.src = imageUrl;
|
||||
}, [setCharactersAnalysis]);
|
||||
|
||||
/**
|
||||
* 根据角色框选数据计算头像URL
|
||||
* 从图片中裁剪出对应的角色头像部分
|
||||
*/
|
||||
const avatarComputed = useMemo(() => {
|
||||
if (!activeImageUrl || charactersAnalysis.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return charactersAnalysis.map((character) => {
|
||||
// 如果已经有头像URL,直接返回
|
||||
if (character.avatarUrl) {
|
||||
return {
|
||||
name: character.role_name,
|
||||
url: character.avatarUrl,
|
||||
};
|
||||
}
|
||||
|
||||
// 异步生成头像URL
|
||||
generateAvatarFromRegion(character, activeImageUrl);
|
||||
|
||||
return {
|
||||
name: character.role_name,
|
||||
url: '', // 初始为空,异步生成完成后会更新
|
||||
};
|
||||
});
|
||||
}, [charactersAnalysis, activeImageUrl, generateAvatarFromRegion]);
|
||||
/**
|
||||
* 上传图片并分析
|
||||
* @param {string} imageUrl - 已上传的图片URL
|
||||
*/
|
||||
const uploadAndAnalyzeImage = useCallback(async (imageUrl: string): Promise<void> => {
|
||||
try {
|
||||
setIsUploading(true);
|
||||
setIsAnalyzing(true);
|
||||
const uploadAndAnalyzeImage = useCallback(
|
||||
async (imageUrl: string): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
// 调用用例处理图片上传和分析
|
||||
await imageStoryUseCase.handleImageUpload(imageUrl);
|
||||
// 调用用例处理图片上传和分析
|
||||
await imageStoryUseCase.handleImageUpload(imageUrl);
|
||||
|
||||
// 获取更新后的数据
|
||||
const updatedStory = imageStoryUseCase.getImageStory();
|
||||
setImageStory(updatedStory);
|
||||
// 获取更新后的数据
|
||||
const updatedStory = imageStoryUseCase.getStoryLogline();
|
||||
const updatedCharacters = imageStoryUseCase.getCharactersAnalysis();
|
||||
const updatedGenres = imageStoryUseCase.getPotentialGenres();
|
||||
const updatedImageStory = imageStoryUseCase.getImageStory();
|
||||
|
||||
// 更新活跃状态
|
||||
setActiveImageUrl(imageUrl);
|
||||
setAnalyzedStoryContent(updatedStory.imageStory || "");
|
||||
setSelectedCategory(updatedStory.storyType || "auto");
|
||||
// 更新所有响应式状态
|
||||
setCharactersAnalysis(updatedCharacters);
|
||||
setPotentialGenres(updatedGenres);
|
||||
setImageStory(updatedImageStory);
|
||||
|
||||
// 将AI分析的故事内容直接更新到统一的故事内容字段
|
||||
setStoryContent(updatedStory || "");
|
||||
|
||||
// 设置第一个分类为默认选中
|
||||
if (updatedGenres.length > 0) {
|
||||
setSelectedCategory(updatedGenres[0]);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('图片上传分析失败:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
setIsAnalyzing(false);
|
||||
}
|
||||
}, [imageStoryUseCase]);
|
||||
// 标记已分析
|
||||
setHasAnalyzed(true);
|
||||
} catch (error) {
|
||||
console.error("图片上传分析失败:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[imageStoryUseCase]
|
||||
);
|
||||
|
||||
/**
|
||||
* 触发生成剧本函数
|
||||
@ -97,15 +276,16 @@ export const useImageStoryServiceHook = (
|
||||
*/
|
||||
const generateScript = useCallback(async (): Promise<string> => {
|
||||
if (!activeImageUrl) {
|
||||
throw new Error('请先上传图片');
|
||||
throw new Error("请先上传图片");
|
||||
}
|
||||
|
||||
if (!analyzedStoryContent) {
|
||||
throw new Error('请先输入或生成故事内容');
|
||||
const finalStoryContent = storyContent;
|
||||
if (!finalStoryContent.trim()) {
|
||||
throw new Error("请先输入或生成故事内容");
|
||||
}
|
||||
|
||||
try {
|
||||
setIsAnalyzing(true);
|
||||
setIsLoading(true);
|
||||
|
||||
// 这里可以调用后端API生成剧本
|
||||
// 暂时返回一个模拟的剧本ID
|
||||
@ -117,110 +297,218 @@ export const useImageStoryServiceHook = (
|
||||
|
||||
return scriptId;
|
||||
} catch (error) {
|
||||
console.error('生成剧本失败:', error);
|
||||
console.error("生成剧本失败:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsAnalyzing(false);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [activeImageUrl, analyzedStoryContent, imageStory]);
|
||||
}, [activeImageUrl, storyContent]);
|
||||
|
||||
/**
|
||||
* 更新故事类型
|
||||
* @param {string} storyType - 新的故事类型
|
||||
*/
|
||||
const updateStoryType = useCallback((storyType: string): void => {
|
||||
imageStoryUseCase.updateStoryType(storyType);
|
||||
setImageStory(prev => ({ ...prev, storyType }));
|
||||
setSelectedCategory(storyType);
|
||||
}, [imageStoryUseCase]);
|
||||
const updateStoryType = useCallback(
|
||||
(storyType: string): void => {
|
||||
imageStoryUseCase.updateStoryType(storyType);
|
||||
setImageStory((prev) => ({ ...prev, storyType }));
|
||||
setSelectedCategory(storyType);
|
||||
},
|
||||
[imageStoryUseCase]
|
||||
);
|
||||
|
||||
/**
|
||||
* 更新故事内容
|
||||
* @param {string} content - 新的故事内容
|
||||
*/
|
||||
const updateStoryContent = useCallback((content: string): void => {
|
||||
imageStoryUseCase.updateStoryContent(content);
|
||||
setImageStory(prev => ({ ...prev, imageStory: content }));
|
||||
setAnalyzedStoryContent(content);
|
||||
setStoryContent(content);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 更新角色名称并同步到相关数据
|
||||
* @param {string} oldName - 旧的角色名称
|
||||
* @param {string} newName - 新的角色名称
|
||||
*/
|
||||
const updateCharacterName = useCallback((oldName: string, newName: string): void => {
|
||||
// 更新角色分析数据中的名称
|
||||
setCharactersAnalysis(prev =>
|
||||
prev.map(char =>
|
||||
char.role_name === oldName
|
||||
? { ...char, role_name: newName }
|
||||
: char
|
||||
)
|
||||
);
|
||||
|
||||
}, [imageStoryUseCase]);
|
||||
// 同步更新故事内容中的角色名称
|
||||
setStoryContent(prev => {
|
||||
// 使用正则表达式进行全局替换,确保大小写匹配
|
||||
const regex = new RegExp(`\\b${oldName}\\b`, 'g');
|
||||
return prev.replace(regex, newName);
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 重置图片故事数据
|
||||
*/
|
||||
const resetImageStory = useCallback((): void => {
|
||||
imageStoryUseCase.resetImageStory();
|
||||
|
||||
// 清理生成的头像URL,避免内存泄漏
|
||||
setCharactersAnalysis(prev => {
|
||||
prev.forEach(char => {
|
||||
if (char.avatarUrl) {
|
||||
URL.revokeObjectURL(char.avatarUrl);
|
||||
}
|
||||
});
|
||||
return [];
|
||||
});
|
||||
|
||||
// 重置所有状态
|
||||
setImageStory({
|
||||
imageUrl: "",
|
||||
imageStory: "",
|
||||
storyType: "auto",
|
||||
});
|
||||
// 重置活跃状态
|
||||
setActiveImageUrl("");
|
||||
setAnalyzedStoryContent("");
|
||||
setStoryContent("");
|
||||
setPotentialGenres([]);
|
||||
setSelectedCategory("auto");
|
||||
setIsAnalyzing(false);
|
||||
setIsUploading(false);
|
||||
|
||||
|
||||
setHasAnalyzed(false);
|
||||
setIsLoading(false);
|
||||
}, [imageStoryUseCase]);
|
||||
|
||||
/**
|
||||
* 完全重置到初始状态(包括预置数据)
|
||||
*/
|
||||
const resetToInitialState = useCallback((): void => {
|
||||
// 清理生成的头像URL,避免内存泄漏
|
||||
setCharactersAnalysis(prev => {
|
||||
prev.forEach(char => {
|
||||
if (char.avatarUrl) {
|
||||
URL.revokeObjectURL(char.avatarUrl);
|
||||
}
|
||||
});
|
||||
return [];
|
||||
});
|
||||
|
||||
// 重置所有状态到初始值
|
||||
setImageStory({
|
||||
imageUrl: "",
|
||||
storyType: "auto",
|
||||
});
|
||||
setActiveImageUrl("https://cdn.qikongjian.com/1755316261_gmw4yq.mp4?vframe/jpg/offset/1");
|
||||
setStoryContent("在一个遥远的未来城市,机器人与人类共存,但一场突如其来的危机打破了平衡。艾米丽,阿尔法,博士互相帮助,共同解决危机。");
|
||||
setCharactersAnalysis([
|
||||
{
|
||||
role_name: "艾米丽",
|
||||
region: {
|
||||
x: 20,
|
||||
y: 20,
|
||||
width: 20,
|
||||
height: 20,
|
||||
},
|
||||
id: "1"
|
||||
},
|
||||
{
|
||||
role_name: "阿尔法",
|
||||
region: {
|
||||
x: 40,
|
||||
y: 40,
|
||||
width: 20,
|
||||
height: 20,
|
||||
},
|
||||
id: "2"
|
||||
},
|
||||
{
|
||||
role_name: "博士",
|
||||
region: {
|
||||
x: 60,
|
||||
y: 60,
|
||||
width: 20,
|
||||
height: 20,
|
||||
},
|
||||
id: "3"
|
||||
},
|
||||
]);
|
||||
setPotentialGenres(["科幻", "冒险", "悬疑"]);
|
||||
setSelectedCategory("auto");
|
||||
setHasAnalyzed(true);
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 触发文件选择并自动分析
|
||||
*/
|
||||
const triggerFileSelectionAndAnalyze = useCallback(async (): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 创建文件输入元素
|
||||
const fileInput = document.createElement("input");
|
||||
fileInput.type = "file";
|
||||
fileInput.accept = "image/*";
|
||||
fileInput.style.display = "none";
|
||||
const triggerFileSelectionAndAnalyze =
|
||||
useCallback(async (): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 创建文件输入元素
|
||||
const fileInput = document.createElement("input");
|
||||
fileInput.type = "file";
|
||||
fileInput.accept = "image/*";
|
||||
fileInput.style.display = "none";
|
||||
|
||||
fileInput.onchange = async (e) => {
|
||||
try {
|
||||
const target = e.target as HTMLInputElement;
|
||||
if (target.files && target.files[0]) {
|
||||
// 使用传入的文件上传函数
|
||||
const uploadedImageUrl = await uploadFile(target.files[0], (progress) => {
|
||||
console.log("上传进度:", progress);
|
||||
});
|
||||
console.log('uploadedImageUrl', uploadedImageUrl)
|
||||
// await uploadAndAnalyzeImage(uploadedImageUrl);
|
||||
setActiveImageUrl(uploadedImageUrl);
|
||||
fileInput.onchange = async (e) => {
|
||||
try {
|
||||
const target = e.target as HTMLInputElement;
|
||||
if (target.files && target.files[0]) {
|
||||
setIsLoading(true);
|
||||
|
||||
// 使用传入的文件上传函数
|
||||
const uploadedImageUrl = await uploadFile(
|
||||
target.files[0],
|
||||
(progress) => {
|
||||
console.log("上传进度:", progress);
|
||||
}
|
||||
);
|
||||
|
||||
// 设置图片URL
|
||||
setActiveImageUrl(uploadedImageUrl);
|
||||
setImageStory((prev) => ({
|
||||
...prev,
|
||||
imageUrl: uploadedImageUrl,
|
||||
}));
|
||||
|
||||
// 自动开始分析
|
||||
await uploadAndAnalyzeImage(uploadedImageUrl);
|
||||
}
|
||||
resolve();
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
// 清理DOM
|
||||
document.body.removeChild(fileInput);
|
||||
}
|
||||
resolve();
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
} finally {
|
||||
// 清理DOM
|
||||
};
|
||||
|
||||
fileInput.oncancel = () => {
|
||||
document.body.removeChild(fileInput);
|
||||
}
|
||||
};
|
||||
reject();
|
||||
};
|
||||
|
||||
fileInput.oncancel = () => {
|
||||
document.body.removeChild(fileInput);
|
||||
reject();
|
||||
};
|
||||
|
||||
document.body.appendChild(fileInput);
|
||||
fileInput.click();
|
||||
});
|
||||
}, [uploadFile]);
|
||||
document.body.appendChild(fileInput);
|
||||
fileInput.click();
|
||||
});
|
||||
}, [uploadFile, uploadAndAnalyzeImage]);
|
||||
|
||||
return {
|
||||
imageStory,
|
||||
activeImageUrl,
|
||||
analyzedStoryContent,
|
||||
storyContent,
|
||||
charactersAnalysis,
|
||||
potentialGenres,
|
||||
selectedCategory,
|
||||
isAnalyzing,
|
||||
isUploading,
|
||||
storyTypeOptions,
|
||||
isLoading,
|
||||
hasAnalyzed,
|
||||
avatarComputed,
|
||||
uploadAndAnalyzeImage,
|
||||
triggerFileSelectionAndAnalyze,
|
||||
generateScript,
|
||||
updateStoryType,
|
||||
updateStoryContent,
|
||||
updateCharacterName,
|
||||
resetImageStory,
|
||||
resetToInitialState,
|
||||
};
|
||||
};
|
||||
|
||||
@ -96,6 +96,24 @@ export interface ImageStoryEntity {
|
||||
imageAnalysis: string;
|
||||
/** 故事分类 */
|
||||
storyType: string;
|
||||
/**角色头像 */
|
||||
roleImage: {
|
||||
/** 名称 */
|
||||
name: string;
|
||||
/** 头像URL(本地create的url) */
|
||||
avatar_url: string;
|
||||
/** 角色区域 小数比例形式 */
|
||||
region: {
|
||||
/** x坐标 */
|
||||
x: number;
|
||||
/** y坐标 */
|
||||
y: number;
|
||||
/** 宽度 */
|
||||
width: number;
|
||||
/** 高度 */
|
||||
height: number;
|
||||
};
|
||||
}[];
|
||||
}
|
||||
/**
|
||||
* 故事模板实体接口
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { ImageStoryEntity } from "../domain/Entities";
|
||||
import { AIGenerateImageStory } from "@/api/movie_start";
|
||||
import { MovieStartDTO, CharacterAnalysis } from "@/api/DTO/movie_start_dto";
|
||||
|
||||
/**
|
||||
* 图片故事用例
|
||||
@ -7,17 +8,26 @@ import { AIGenerateImageStory } from "@/api/movie_start";
|
||||
*/
|
||||
export class ImageStoryUseCase {
|
||||
/** 当前图片故事数据 */
|
||||
private imageStory: Partial<ImageStoryEntity> = {
|
||||
imageStory: Partial<ImageStoryEntity> = {
|
||||
imageUrl: "",
|
||||
imageStory: "",
|
||||
storyType: "auto",
|
||||
};
|
||||
|
||||
/** 故事梗概 */
|
||||
storyLogline: string = "";
|
||||
|
||||
/** 角色头像及名称数据 */
|
||||
charactersAnalysis: CharacterAnalysis[] = [];
|
||||
|
||||
/** 分类数据 */
|
||||
potentialGenres: string[] = [];
|
||||
|
||||
/** 是否正在分析图片 */
|
||||
private isAnalyzing: boolean = false;
|
||||
isAnalyzing: boolean = false;
|
||||
|
||||
/** 是否正在上传 */
|
||||
private isUploading: boolean = false;
|
||||
isUploading: boolean = false;
|
||||
|
||||
constructor() {}
|
||||
|
||||
@ -29,6 +39,30 @@ export class ImageStoryUseCase {
|
||||
return { ...this.imageStory };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取故事梗概
|
||||
* @returns {string} 故事梗概
|
||||
*/
|
||||
getStoryLogline(): string {
|
||||
return this.storyLogline;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取角色分析数据
|
||||
* @returns {CharacterAnalysis[]} 角色分析数据
|
||||
*/
|
||||
getCharactersAnalysis(): CharacterAnalysis[] {
|
||||
return [...this.charactersAnalysis];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分类数据
|
||||
* @returns {string[]} 分类数据
|
||||
*/
|
||||
getPotentialGenres(): string[] {
|
||||
return [...this.potentialGenres];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分析状态
|
||||
* @returns {boolean} 是否正在分析
|
||||
@ -62,6 +96,9 @@ export class ImageStoryUseCase {
|
||||
imageStory: "",
|
||||
storyType: "auto",
|
||||
};
|
||||
this.storyLogline = "";
|
||||
this.charactersAnalysis = [];
|
||||
this.potentialGenres = [];
|
||||
this.isAnalyzing = false;
|
||||
this.isUploading = false;
|
||||
}
|
||||
@ -96,20 +133,20 @@ export class ImageStoryUseCase {
|
||||
* 使用AI分析图片
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
private async analyzeImageWithAI(): Promise<void> {
|
||||
async analyzeImageWithAI(): Promise<void> {
|
||||
try {
|
||||
// 调用AI分析接口
|
||||
const response = await AIGenerateImageStory(this.imageStory as ImageStoryEntity);
|
||||
const response = await AIGenerateImageStory({
|
||||
imageUrl: this.imageStory.imageUrl || "",
|
||||
user_text: this.imageStory.imageStory || "",
|
||||
});
|
||||
|
||||
if (response.successful && response.data) {
|
||||
const { imageAnalysis, category } = response.data;
|
||||
// 解析并存储新的数据结构
|
||||
this.parseAndStoreAnalysisData(response.data);
|
||||
|
||||
// 更新分析结果和分类
|
||||
this.setImageStory({
|
||||
imageAnalysis,
|
||||
storyType: category || "auto",
|
||||
imageStory: imageAnalysis, // 将AI分析结果作为默认故事内容
|
||||
});
|
||||
// 组合成ImageStoryEntity
|
||||
this.composeImageStoryEntity(response.data);
|
||||
} else {
|
||||
throw new Error("AI分析失败");
|
||||
}
|
||||
@ -119,6 +156,46 @@ export class ImageStoryUseCase {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析并存储分析数据到类属性中
|
||||
* @param {MovieStartDTO} data - AI分析返回的数据
|
||||
*/
|
||||
parseAndStoreAnalysisData(data: MovieStartDTO): void {
|
||||
// 存储故事梗概
|
||||
this.storyLogline = data.story_logline || "";
|
||||
|
||||
// 存储角色头像及名称数据
|
||||
this.charactersAnalysis = data.characters_analysis || [];
|
||||
|
||||
// 存储分类数据
|
||||
this.potentialGenres = data.potential_genres || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 组合成ImageStoryEntity
|
||||
* @param {MovieStartDTO} data - AI分析返回的数据
|
||||
*/
|
||||
composeImageStoryEntity(data: MovieStartDTO): void {
|
||||
// 将角色数据转换为ImageStoryEntity需要的格式
|
||||
const roleImage = data.characters_analysis?.map(character => ({
|
||||
name: character.role_name,
|
||||
avatar_url: "", // 这里需要根据实际情况设置头像URL
|
||||
region: {
|
||||
x: character.region.x,
|
||||
y: character.region.y,
|
||||
width: character.region.width,
|
||||
height: character.region.height,
|
||||
}
|
||||
})) || [];
|
||||
|
||||
// 更新ImageStoryEntity
|
||||
this.setImageStory({
|
||||
imageAnalysis: data.story_logline || "",
|
||||
storyType: data.potential_genres?.[0] || "auto", // 使用第一个分类作为故事类型
|
||||
roleImage,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新故事类型
|
||||
* @param {string} storyType - 新的故事类型
|
||||
@ -137,7 +214,7 @@ export class ImageStoryUseCase {
|
||||
|
||||
/**
|
||||
* 获取故事类型选项
|
||||
* @returns {Array<{key: string, label: string}>} 故事类型选项数组
|
||||
* @returns {Array<{key: string, label: string}> 故事类型选项数组
|
||||
*/
|
||||
getStoryTypeOptions(): Array<{ key: string; label: string }> {
|
||||
return [
|
||||
@ -149,5 +226,53 @@ export class ImageStoryUseCase {
|
||||
{ key: "comedy", label: "Comedy" },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理角色数据,解析并存储到类属性中
|
||||
* @param {CharacterAnalysis[]} characters - 角色分析数据
|
||||
*/
|
||||
processCharacterData(characters: CharacterAnalysis[]): void {
|
||||
this.charactersAnalysis = characters.map(character => ({
|
||||
...character,
|
||||
region: {
|
||||
x: character.region.x,
|
||||
y: character.region.y,
|
||||
width: character.region.width,
|
||||
height: character.region.height,
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定角色的区域坐标
|
||||
* @param {string} characterName - 角色名称
|
||||
* @returns {CharacterAnalysis['region'] | null} 角色区域坐标,如果未找到则返回null
|
||||
*/
|
||||
getCharacterRegion(characterName: string): CharacterAnalysis['region'] | null {
|
||||
const character = this.charactersAnalysis.find(char => char.role_name === characterName);
|
||||
return character ? character.region : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新角色头像URL
|
||||
* @param {string} characterName - 角色名称
|
||||
* @param {string} avatarUrl - 头像URL
|
||||
*/
|
||||
updateCharacterAvatar(characterName: string, avatarUrl: string): void {
|
||||
const character = this.charactersAnalysis.find(char => char.role_name === characterName);
|
||||
if (character) {
|
||||
// 更新角色头像URL(这里需要根据实际的数据结构来调整)
|
||||
// 由于CharacterAnalysis接口中没有avatar_url字段,这里只是示例
|
||||
console.log(`更新角色 ${characterName} 的头像URL: ${avatarUrl}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有角色名称
|
||||
* @returns {string[]} 角色名称数组
|
||||
*/
|
||||
getAllCharacterNames(): string[] {
|
||||
return this.charactersAnalysis.map(char => char.role_name);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,12 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useState,
|
||||
useRef,
|
||||
useEffect,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
} from "react";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
@ -23,8 +17,11 @@ import {
|
||||
Plus,
|
||||
LayoutTemplate,
|
||||
ImagePlay,
|
||||
Sparkles,
|
||||
RotateCcw,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
import { Dropdown, Modal, Tooltip, Upload, Image } from "antd";
|
||||
import { Dropdown, Modal, Tooltip, Upload, Image, Spin } from "antd";
|
||||
import { PlusOutlined, UploadOutlined, EyeOutlined } from "@ant-design/icons";
|
||||
import { StoryTemplateEntity } from "@/app/service/domain/Entities";
|
||||
import { useImageStoryServiceHook } from "@/app/service/Interaction/ImageStoryService";
|
||||
@ -133,6 +130,14 @@ const customAudioPlayerStyles = `
|
||||
.scale-102 {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
/* 文本截断类 */
|
||||
.line-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
`;
|
||||
|
||||
/**模板故事模式弹窗组件 */
|
||||
@ -486,31 +491,13 @@ export function ChatInputBox() {
|
||||
// 模板故事弹窗状态
|
||||
const [isTemplateModalOpen, setIsTemplateModalOpen] = useState(false);
|
||||
|
||||
// 图片故事模式状态
|
||||
const [isPhotoStoryMode, setIsPhotoStoryMode] = useState(false);
|
||||
// 图片故事弹窗状态
|
||||
const [isPhotoStoryModalOpen, setIsPhotoStoryModalOpen] = useState(false);
|
||||
|
||||
// 共享状态 - 需要在不同渲染函数间共享
|
||||
const [script, setScript] = useState(""); // 用户输入的脚本内容
|
||||
const router = useRouter();
|
||||
|
||||
// 子组件引用,用于调用子组件方法
|
||||
const photoStoryModeRef = useRef<{
|
||||
enterPhotoStoryMode: () => void;
|
||||
getStoryContent: () => string;
|
||||
}>(null);
|
||||
|
||||
// 响应式管理输入框内容:当在图片故事模式下,从子组件获取故事内容
|
||||
useEffect(() => {
|
||||
if (isPhotoStoryMode && photoStoryModeRef.current) {
|
||||
const storyContent = photoStoryModeRef.current.getStoryContent();
|
||||
if (storyContent) {
|
||||
setScript(storyContent);
|
||||
}
|
||||
}
|
||||
}, [isPhotoStoryMode]);
|
||||
|
||||
|
||||
|
||||
const [loadingIdea, setLoadingIdea] = useState(false); // 获取创意建议时的加载状态
|
||||
const [isCreating, setIsCreating] = useState(false); // 视频创建过程中的加载状态
|
||||
|
||||
@ -522,10 +509,8 @@ export function ChatInputBox() {
|
||||
videoDuration: "1min",
|
||||
});
|
||||
|
||||
// 退出图片故事模式
|
||||
const exitPhotoStoryMode = () => {
|
||||
setIsPhotoStoryMode(false);
|
||||
};
|
||||
// 配置项显示控制状态
|
||||
const [showConfigOptions, setShowConfigOptions] = useState(false);
|
||||
|
||||
const handleGetIdea = () => {
|
||||
if (loadingIdea) return;
|
||||
@ -538,22 +523,14 @@ export function ChatInputBox() {
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
|
||||
|
||||
// 当进入图片故事模式时,调用子组件方法
|
||||
const enterPhotoStoryMode = () => {
|
||||
setIsPhotoStoryMode(true);
|
||||
|
||||
// 调用子组件的进入方法
|
||||
if (photoStoryModeRef.current) {
|
||||
photoStoryModeRef.current.enterPhotoStoryMode();
|
||||
}
|
||||
};
|
||||
|
||||
// Handle creating video
|
||||
|
||||
const handleCreateVideo = async () => {
|
||||
setIsCreating(true);
|
||||
if (!script) {
|
||||
setIsCreating(false);
|
||||
return;
|
||||
}
|
||||
const User = JSON.parse(localStorage.getItem("currentUser") || "{}");
|
||||
|
||||
// 创建剧集数据
|
||||
@ -563,12 +540,12 @@ export function ChatInputBox() {
|
||||
mode: configOptions.mode,
|
||||
resolution: configOptions.resolution,
|
||||
language: configOptions.language,
|
||||
video_duration: configOptions.videoDuration
|
||||
video_duration: configOptions.videoDuration,
|
||||
};
|
||||
|
||||
// 调用创建剧集API
|
||||
const episodeResponse = await createScriptEpisodeNew(episodeData);
|
||||
console.log('episodeResponse', episodeResponse);
|
||||
console.log("episodeResponse", episodeResponse);
|
||||
if (episodeResponse.code !== 0) {
|
||||
console.error(`创建剧集失败: ${episodeResponse.message}`);
|
||||
alert(`创建剧集失败: ${episodeResponse.message}`);
|
||||
@ -578,7 +555,7 @@ export function ChatInputBox() {
|
||||
// let episodeId = '9c34fcc4-c8d8-44fc-879e-9bd56f608c76';
|
||||
router.push(`/create/work-flow?episodeId=${episodeId}`);
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="video-tool-component relative max-w-[1080px] w-full left-[50%] translate-x-[-50%]">
|
||||
@ -606,138 +583,148 @@ export function ChatInputBox() {
|
||||
|
||||
{/* 主要内容区域 - 简化层级,垂直居中 */}
|
||||
<div
|
||||
className={`flex items-center justify-center p-1 transition-all duration-300 ${
|
||||
isExpanded ? "h-[16px]" : "h-[60px]"
|
||||
data-alt="+ ---------"
|
||||
className={`flex items-center justify-center p-1 transition-all duration-300 relative ${
|
||||
isExpanded ? "h-[16px]" : "h-auto"
|
||||
}`}
|
||||
>
|
||||
{/* 输入框和Action按钮 - 只在展开状态显示 */}
|
||||
{/* 右上角齿轮图标和配置项 */}
|
||||
{!isExpanded && (
|
||||
<div className="flex items-center gap-3 w-full pl-3">
|
||||
{/* 模式选择下拉菜单 */}
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: "template",
|
||||
label: (
|
||||
<div className="flex items-center gap-3 px-3 py-2">
|
||||
<LayoutTemplate className="w-5 h-5 text-white/80" />
|
||||
<span className="text-sm text-white/90">
|
||||
Template Story
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "photo",
|
||||
label: (
|
||||
<div className="flex items-center gap-3 px-3 py-2">
|
||||
<ImagePlay className="w-5 h-5 text-white/80" />
|
||||
<span className="text-sm text-white/90">
|
||||
Photo Story
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
onClick: ({ key }) => {
|
||||
if (key === "template") {
|
||||
setIsTemplateModalOpen(true);
|
||||
} else if (key === "photo") {
|
||||
enterPhotoStoryMode();
|
||||
}
|
||||
console.log("Selected mode:", key);
|
||||
},
|
||||
<div className="absolute top-2 right-2 z-10 flex items-center">
|
||||
{/* 配置项显示区域 - 从齿轮图标右侧边缘滑入 */}
|
||||
<div
|
||||
className={`relative bottom-16 flex items-center gap-2 bg-white/[0.18] backdrop-blur-[40px] border border-white/[0.12] rounded-lg p-2 transition-all duration-500 ease-out mr-2 ${
|
||||
showConfigOptions
|
||||
? " opacity-100"
|
||||
: "opacity-0 pointer-events-none"
|
||||
}`}
|
||||
style={{
|
||||
transformOrigin: "left center",
|
||||
willChange: "transform, opacity",
|
||||
}}
|
||||
trigger={["click"]}
|
||||
placement="topLeft"
|
||||
overlayClassName="mode-dropdown"
|
||||
>
|
||||
<button
|
||||
data-alt="mode-selector"
|
||||
className="flex items-center justify-center w-10 h-10 bg-white/[0.08] backdrop-blur-xl border border-white/[0.12] rounded-lg text-white/80 hover:bg-white/[0.12] hover:border-white/[0.2] transition-all duration-200 shadow-[0_4px_16px_rgba(0,0,0,0.2)]"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</Dropdown>
|
||||
|
||||
{/* 图片故事模式UI - 始终渲染,通过ref调用方法 */}
|
||||
<PhotoStoryMode
|
||||
ref={photoStoryModeRef}
|
||||
onExitMode={exitPhotoStoryMode}
|
||||
isVisible={isPhotoStoryMode}
|
||||
/>
|
||||
|
||||
<div className="video-prompt-editor relative flex flex-1 items-center rounded-[6px]">
|
||||
{/* 简单的文本输入框 */}
|
||||
<input
|
||||
type="text"
|
||||
value={script}
|
||||
onChange={(e) => setScript(e.target.value)}
|
||||
placeholder="Describe the content you want to action..."
|
||||
className="flex-1 w-0 pl-[10px] pr-[10px] py-[10px] rounded-[10px] leading-[20px] text-sm border-none bg-transparent text-white placeholder:text-white/[0.40] focus:outline-none"
|
||||
/>
|
||||
|
||||
{/* 获取创意按钮 */}
|
||||
{!script && (
|
||||
<div className="absolute top-[50%] right-[10px] z-10 translate-y-[-50%] flex items-center gap-1">
|
||||
<span className="text-[14px] leading-[20px] text-white/[0.40]">Get an </span>
|
||||
<button
|
||||
className="idea-link inline-flex items-center gap-0.5 text-white/[0.50] font-normal underline hover:text-white/[0.70] transition-colors"
|
||||
onClick={() => handleGetIdea()}
|
||||
>
|
||||
{loadingIdea ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Lightbulb className="w-4 h-4" />
|
||||
idea
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<ConfigOptions
|
||||
config={configOptions}
|
||||
onConfigChange={(key, value) =>
|
||||
setConfigOptions((prev) => ({ ...prev, [key]: value }))
|
||||
}
|
||||
compact={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action按钮 */}
|
||||
<div className="relative group">
|
||||
<div className="relative w-40 h-12 opacity-90 overflow-hidden rounded-xl bg-black z-10">
|
||||
<div className="absolute z-10 -translate-x-32 group-hover:translate-x-[20rem] ease-in transistion-all duration-700 h-full w-32 bg-gradient-to-r from-gray-500 to-white/10 opacity-30 -skew-x-12"></div>
|
||||
{/* 配置项显示控制按钮 - 齿轮图标,位置完全固定 */}
|
||||
<Tooltip title="config" placement="top">
|
||||
<button
|
||||
data-alt="config-toggle-button"
|
||||
className="flex items-center justify-center w-8 h-8 bg-white/[0.1] hover:bg-white/[0.2] rounded-lg border border-white/[0.2] transition-all duration-200"
|
||||
onClick={() => setShowConfigOptions(!showConfigOptions)}
|
||||
>
|
||||
<Settings className="w-4 h-4 text-white/80" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="absolute flex items-center justify-center text-white z-[1] opacity-90 rounded-2xl inset-0.5 bg-black">
|
||||
{/* 输入框和Action按钮 - 只在展开状态显示 */}
|
||||
{!isExpanded && (
|
||||
<div className="flex flex-col gap-3 w-full pl-3">
|
||||
{/* 第一行:输入框 */}
|
||||
<div className="video-prompt-editor relative flex flex-col gap-3 flex-1">
|
||||
{/* 文本输入框 - 改为textarea */}
|
||||
<textarea
|
||||
value={script}
|
||||
onChange={(e) => setScript(e.target.value)}
|
||||
placeholder="Describe the content you want to action..."
|
||||
className="w-full pl-[10px] pr-[10px] py-[14px] rounded-[10px] leading-[20px] text-sm border-none bg-transparent text-white placeholder:text-white/[0.40] focus:outline-none resize-none min-h-[48px] max-h-[120px] overflow-y-auto"
|
||||
rows={1}
|
||||
onInput={(e) => {
|
||||
const target = e.target as HTMLTextAreaElement;
|
||||
target.style.height = "auto";
|
||||
target.style.height =
|
||||
Math.min(target.scrollHeight, 120) + "px";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 第二行:功能按钮和Action按钮 - 同一行 */}
|
||||
<div className="flex items-center justify-between">
|
||||
{/* 左侧功能按钮区域 */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 获取创意按钮 */}
|
||||
<Tooltip
|
||||
title="Get creative ideas for your story"
|
||||
placement="top"
|
||||
>
|
||||
<button
|
||||
name="text"
|
||||
className="input font-semibold text-base h-full opacity-90 w-full px-10 py-2 rounded-xl bg-black flex items-center justify-center"
|
||||
onClick={isCreating ? undefined : handleCreateVideo}
|
||||
data-alt="get-idea-button"
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-white/[0.70] hover:text-white transition-colors"
|
||||
onClick={() => handleGetIdea()}
|
||||
>
|
||||
{isCreating ? (
|
||||
<>
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
Actioning...
|
||||
</>
|
||||
{loadingIdea ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Clapperboard className="w-5 h-5" />
|
||||
Action
|
||||
</>
|
||||
<Lightbulb className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="absolute duration-1000 group-hover:animate-spin w-full h-[80px] bg-gradient-to-r from-[rgb(106,244,249)] to-[rgb(199,59,255)] blur-[30px]"></div>
|
||||
</Tooltip>
|
||||
|
||||
{/* 分隔线 */}
|
||||
<div className="w-px h-4 bg-white/[0.20]"></div>
|
||||
|
||||
{/* 模板故事按钮 */}
|
||||
<Tooltip title="Choose from movie templates" placement="top">
|
||||
<button
|
||||
data-alt="template-story-button"
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-white/[0.70] hover:text-white transition-colors"
|
||||
onClick={() => setIsTemplateModalOpen(true)}
|
||||
>
|
||||
<LayoutTemplate className="w-4 h-4" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* 分隔线 */}
|
||||
<div className="w-px h-4 bg-white/[0.20]"></div>
|
||||
|
||||
{/* 图片故事按钮 */}
|
||||
<Tooltip title="Create movie from image" placement="top">
|
||||
<button
|
||||
data-alt="photo-story-button"
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-white/[0.70] hover:text-white transition-colors"
|
||||
onClick={() => setIsPhotoStoryModalOpen(true)}
|
||||
>
|
||||
<ImagePlay className="w-4 h-4" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* 图片故事弹窗 */}
|
||||
<PhotoStoryModal
|
||||
isOpen={isPhotoStoryModalOpen}
|
||||
onClose={() => setIsPhotoStoryModalOpen(false)}
|
||||
onConfirm={(storyContent, category) => {
|
||||
setScript(storyContent);
|
||||
setIsPhotoStoryModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 右侧Action按钮 */}
|
||||
<ActionButton
|
||||
isCreating={isCreating}
|
||||
handleCreateVideo={handleCreateVideo}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 配置选项区域 */}
|
||||
<ConfigOptions
|
||||
{/* 配置选项区域 - 已移至右上角 */}
|
||||
{/* <ConfigOptions
|
||||
config={configOptions}
|
||||
onConfigChange={(key, value) =>
|
||||
setConfigOptions((prev) => ({ ...prev, [key]: value }))
|
||||
}
|
||||
/>
|
||||
/> */}
|
||||
|
||||
{/* 模板故事弹窗 */}
|
||||
<RenderTemplateStoryMode
|
||||
@ -748,6 +735,40 @@ export function ChatInputBox() {
|
||||
);
|
||||
}
|
||||
|
||||
// 创建视频按钮
|
||||
const ActionButton = ({
|
||||
isCreating,
|
||||
handleCreateVideo,
|
||||
}: {
|
||||
isCreating: boolean;
|
||||
handleCreateVideo: () => void;
|
||||
}) => {
|
||||
return (
|
||||
<div className="relative group">
|
||||
<div
|
||||
data-alt="action-button"
|
||||
className="relative w-12 h-12 opacity-90 overflow-hidden rounded-xl bg-black z-10"
|
||||
>
|
||||
<div className="absolute z-10 -translate-x-12 group-hover:translate-x-12 transition-all duration-700 h-full w-12 bg-gradient-to-r from-gray-500 to-white/10 opacity-30 -skew-x-12"></div>
|
||||
<div className="absolute flex items-center justify-center text-white z-[1] opacity-90 rounded-xl inset-0.5 bg-black">
|
||||
<button
|
||||
name="text"
|
||||
className="w-full h-full opacity-90 rounded-xl bg-black flex items-center justify-center"
|
||||
onClick={isCreating ? undefined : handleCreateVideo}
|
||||
>
|
||||
{isCreating ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<Clapperboard className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="absolute duration-1000 group-hover:animate-spin w-full h-12 bg-gradient-to-r from-[rgb(106,244,249)] to-[rgb(199,59,255)] blur-[20px]"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 配置选项组件
|
||||
* 提供视频创建的各种配置选项,位于输入框下方
|
||||
@ -755,6 +776,7 @@ export function ChatInputBox() {
|
||||
const ConfigOptions = ({
|
||||
config,
|
||||
onConfigChange,
|
||||
compact = false,
|
||||
}: {
|
||||
config: {
|
||||
mode: string;
|
||||
@ -763,6 +785,7 @@ const ConfigOptions = ({
|
||||
videoDuration: string;
|
||||
};
|
||||
onConfigChange: (key: string, value: string) => void;
|
||||
compact?: boolean;
|
||||
}) => {
|
||||
const configItems = [
|
||||
{
|
||||
@ -805,13 +828,12 @@ const ConfigOptions = ({
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 mt-3">
|
||||
<div className={`flex items-center gap-3 ${compact ? "gap-2" : "mt-3"}`}>
|
||||
{configItems.map((item) => {
|
||||
const IconComponent = item.icon;
|
||||
const currentOption = item.options.find(
|
||||
(opt) => opt.value === config[item.key as keyof typeof config]
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
key={item.key}
|
||||
@ -834,12 +856,22 @@ const ConfigOptions = ({
|
||||
>
|
||||
<button
|
||||
data-alt={`config-${item.key}`}
|
||||
className="flex items-center gap-2 px-2.5 py-1.5 bg-white/[0.05] backdrop-blur-xl border border-white/[0.1] rounded-lg text-white/80 hover:bg-white/[0.08] hover:border-white/[0.2] transition-all duration-200"
|
||||
className={`flex items-center gap-2 px-2.5 py-1.5 bg-white/[0.05] backdrop-blur-xl border border-white/[0.1] rounded-lg text-white/80 hover:bg-white/[0.08] hover:border-white/[0.2] transition-all duration-200 ${
|
||||
compact ? "px-2 py-1" : ""
|
||||
}`}
|
||||
>
|
||||
<IconComponent className="w-3.5 h-3.5" />
|
||||
<span className="text-xs">{currentOption?.label}</span>
|
||||
<IconComponent
|
||||
className={`${compact ? "w-3 h-3" : "w-3.5 h-3.5"}`}
|
||||
/>
|
||||
<span className={`${compact ? "text-xs" : "text-xs"}`}>
|
||||
{currentOption?.label}
|
||||
</span>
|
||||
{currentOption?.isVip && (
|
||||
<Crown className="w-2.5 h-2.5 text-yellow-500" />
|
||||
<Crown
|
||||
className={`${
|
||||
compact ? "w-2 h-2" : "w-2.5 h-2.5"
|
||||
} text-yellow-500`}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</Dropdown>
|
||||
@ -850,128 +882,308 @@ const ConfigOptions = ({
|
||||
};
|
||||
|
||||
/**
|
||||
* 图片故事模式组件
|
||||
* 显示图片预览、分析状态和故事类型选择器
|
||||
* 使用自己的hook管理状态,通过ref暴露方法给父组件
|
||||
* 图片故事弹窗组件
|
||||
* 提供图片上传、AI分析和故事生成功能,支持动态UI变化
|
||||
*/
|
||||
const PhotoStoryMode = forwardRef<
|
||||
{
|
||||
enterPhotoStoryMode: () => void;
|
||||
getStoryContent: () => string;
|
||||
},
|
||||
{
|
||||
onExitMode: () => void;
|
||||
isVisible: boolean;
|
||||
}
|
||||
>(({ onExitMode, isVisible }, ref) => {
|
||||
// 使用图片故事服务hook管理自己的状态
|
||||
const PhotoStoryModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (storyContent: string, category: string) => void;
|
||||
}) => {
|
||||
// 使用图片故事服务hook管理状态
|
||||
const {
|
||||
activeImageUrl,
|
||||
storyContent,
|
||||
charactersAnalysis,
|
||||
potentialGenres,
|
||||
selectedCategory,
|
||||
isAnalyzing,
|
||||
isUploading,
|
||||
storyTypeOptions,
|
||||
analyzedStoryContent,
|
||||
isLoading,
|
||||
hasAnalyzed,
|
||||
updateStoryType,
|
||||
resetImageStory,
|
||||
updateStoryContent,
|
||||
updateCharacterName,
|
||||
resetImageStory,
|
||||
resetToInitialState,
|
||||
triggerFileSelectionAndAnalyze,
|
||||
avatarComputed,
|
||||
} = useImageStoryServiceHook();
|
||||
|
||||
// 通过ref暴露方法给父组件
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
enterPhotoStoryMode: () => {
|
||||
// 触发文件选择和分析
|
||||
triggerFileSelectionAndAnalyze().catch((error: unknown) => {
|
||||
console.error("Failed to enter photo story mode:", error);
|
||||
});
|
||||
},
|
||||
getStoryContent: () => analyzedStoryContent || "",
|
||||
}),
|
||||
[triggerFileSelectionAndAnalyze, analyzedStoryContent]
|
||||
);
|
||||
|
||||
// 处理退出模式
|
||||
const handleExitMode = () => {
|
||||
// 重置状态
|
||||
const handleClose = () => {
|
||||
resetImageStory();
|
||||
onExitMode();
|
||||
onClose();
|
||||
};
|
||||
|
||||
// 如果不显示,返回null
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
// 处理图片上传
|
||||
const handleImageUpload = async () => {
|
||||
try {
|
||||
await triggerFileSelectionAndAnalyze();
|
||||
} catch (error) {
|
||||
console.error("Failed to upload image:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理确认
|
||||
const handleConfirm = () => {
|
||||
if (storyContent.trim()) {
|
||||
onConfirm(storyContent, selectedCategory);
|
||||
}
|
||||
};
|
||||
|
||||
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组件 */}
|
||||
{activeImageUrl && (
|
||||
<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)]">
|
||||
<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",
|
||||
}}
|
||||
/>
|
||||
<Modal
|
||||
open={isOpen}
|
||||
onCancel={handleClose}
|
||||
footer={null}
|
||||
width="80%"
|
||||
style={{ maxWidth: "1000px" }}
|
||||
className="photo-story-modal"
|
||||
closeIcon={
|
||||
<div className="w-6 h-6 bg-white/10 rounded-full flex items-center justify-center hover:bg-white/20 transition-colors">
|
||||
<span className="text-white/70 text-lg leading-none flex items-center justify-center">
|
||||
×
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Spin spinning={isLoading} tip="Processing...">
|
||||
<div className="rounded-2xl">
|
||||
{/* 弹窗头部 */}
|
||||
<div className="flex items-center gap-3 p-6 border-b border-white/[0.1]">
|
||||
<ImagePlay className="w-6 h-6 text-blue-400" />
|
||||
<h2 className="text-xl font-bold text-white">
|
||||
Photo Story Creation
|
||||
</h2>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 删除图片按钮 - 简洁样式 */}
|
||||
{activeImageUrl && (
|
||||
<button
|
||||
onClick={handleExitMode}
|
||||
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>
|
||||
)}
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
<div className="w-full bg-white/[0.05] border border-white/[0.1] rounded-xl p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
{/* 左侧:图片上传 */}
|
||||
<div className="flex-shrink-0">
|
||||
<div
|
||||
data-alt="image-upload-area"
|
||||
className="w-20 h-20 border-2 border-dashed border-white/20 rounded-lg flex flex-col items-center justify-center hover:border-white/40 transition-all duration-300 cursor-pointer bg-white/[0.02] hover:bg-white/[0.05] hover:scale-105"
|
||||
onClick={handleImageUpload}
|
||||
>
|
||||
{activeImageUrl ? (
|
||||
<div className="relative w-full h-full">
|
||||
<img
|
||||
src={activeImageUrl}
|
||||
alt="Story inspiration"
|
||||
className="w-full h-full object-cover rounded-lg"
|
||||
/>
|
||||
<Tooltip title="Clear all content !!! " placement="top">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
resetImageStory();
|
||||
resetToInitialState();
|
||||
}}
|
||||
className="absolute top-1 right-1 w-4 h-4 bg-black/60 hover:bg-black/80 rounded-full flex items-center justify-center text-white/80 hover:text-white transition-colors"
|
||||
data-alt="clear-all-button"
|
||||
>
|
||||
<Trash2 className="w-2.5 h-2.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-white/60">
|
||||
<Upload className="w-6 h-6 mx-auto mb-1 opacity-50" />
|
||||
<p className="text-xs">Upload</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 分析状态指示器 */}
|
||||
{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>
|
||||
{/* 中间:头像展示(分析后显示) */}
|
||||
{hasAnalyzed && avatarComputed.length > 0 && (
|
||||
<div className="flex-1 animate-in fade-in-0 slide-in-from-left-4 duration-300">
|
||||
<div className="flex gap-2 n justify-start">
|
||||
{avatarComputed.map((avatar, index) => (
|
||||
<div
|
||||
key={`${avatar.name}-${index}`}
|
||||
className="flex flex-col items-center"
|
||||
>
|
||||
<div className="relative w-14 h-14 rounded-sm overflow-hidden bg-white/[0.05] border border-white/[0.1] mb-2 group">
|
||||
<img
|
||||
src={avatar.url}
|
||||
alt={avatar.name}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
// 如果裁剪的头像加载失败,回退到原图
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.src = activeImageUrl;
|
||||
}}
|
||||
/>
|
||||
{/* 删除角色按钮 - 使用Tooltip并调整z-index避免被遮挡 */}
|
||||
<Tooltip title="Remove this character from the movie" placement="top">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// 从角色分析中删除该角色
|
||||
const updatedCharacters =
|
||||
charactersAnalysis.filter(
|
||||
(char) => char.role_name !== avatar.name
|
||||
);
|
||||
// 从故事内容中删除该角色名称
|
||||
const updatedStory = storyContent
|
||||
.replace(
|
||||
new RegExp(`\\b${avatar.name}\\b`, "g"),
|
||||
""
|
||||
)
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
// 更新状态
|
||||
updateStoryContent(updatedStory);
|
||||
// 注意:这里需要直接更新 charactersAnalysis,但 hook 中没有提供 setter
|
||||
// 暂时通过重新分析图片来刷新状态
|
||||
if (activeImageUrl) {
|
||||
triggerFileSelectionAndAnalyze();
|
||||
}
|
||||
}}
|
||||
className="absolute top-1 right-1 w-4 h-4 bg-black/[0.05] border border-black/[0.1] text-white rounded-full flex items-center justify-center transition-colors opacity-0 group-hover:opacity-100 z-10"
|
||||
>
|
||||
<Trash2 className="w-2.5 h-2.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="relative group">
|
||||
<input
|
||||
type="text"
|
||||
value={avatar.name}
|
||||
onChange={(e) => {
|
||||
const newName = e.target.value.trim();
|
||||
if (newName && newName !== avatar.name) {
|
||||
updateCharacterName(avatar.name, newName);
|
||||
}
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const newName = e.target.value.trim();
|
||||
if (newName && newName !== avatar.name) {
|
||||
updateCharacterName(avatar.name, newName);
|
||||
}
|
||||
}}
|
||||
className="w-16 text-center text-sm text-white/80 bg-transparent border-none outline-none focus:ring-1 focus:ring-blue-400/50 rounded px-1 py-0.5 transition-all duration-200"
|
||||
style={{ textAlign: "center" }}
|
||||
/>
|
||||
<div className="absolute inset-0 border border-transparent group-hover:border-white/20 rounded transition-all duration-200 pointer-events-none"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</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}
|
||||
{/* 右侧:分类选择(分析后显示) */}
|
||||
{hasAnalyzed && potentialGenres.length > 0 && (
|
||||
<div className="flex-shrink-0 animate-in fade-in-0 slide-in-from-right-4 duration-300">
|
||||
<div className="flex gap-2">
|
||||
{["Auto", ...potentialGenres].map((genre) => (
|
||||
<button
|
||||
key={genre}
|
||||
onClick={() => updateStoryType(genre)}
|
||||
className={`px-3 py-1.5 text-xs rounded-lg transition-all duration-200 whitespace-nowrap ${
|
||||
selectedCategory === genre
|
||||
? "bg-blue-500/20 border border-blue-500/40 text-blue-300"
|
||||
: "bg-white/[0.05] border border-white/[0.1] text-white/60 hover:bg-white/[0.08] hover:text-white/80"
|
||||
}`}
|
||||
>
|
||||
{genre}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
<div className="flex items-start gap-4 mt-2">
|
||||
{/* 文本输入框 */}
|
||||
<div className="flex-1 min-w-0 relative pr-20">
|
||||
<textarea
|
||||
data-alt="story-description-input"
|
||||
value={storyContent}
|
||||
onChange={(e) => updateStoryContent(e.target.value)}
|
||||
placeholder={
|
||||
hasAnalyzed
|
||||
? "AI analyzed story content..."
|
||||
: "Describe your story idea..."
|
||||
}
|
||||
className="w-full bg-transparent border-none outline-none resize-none text-white placeholder:text-white/40 text-sm leading-relaxed"
|
||||
style={{ minHeight: "60px" }}
|
||||
onInput={(e) => {
|
||||
const target = e.target as HTMLTextAreaElement;
|
||||
target.style.height = "auto";
|
||||
target.style.height =
|
||||
Math.max(target.scrollHeight, 60) + "px";
|
||||
}}
|
||||
/>
|
||||
<div className="absolute bottom-1 right-0 flex gap-2">
|
||||
{!hasAnalyzed ? (
|
||||
// 分析按钮 - 使用ActionButton样式
|
||||
<Tooltip title="Analyze image content" placement="top">
|
||||
<div className="relative group">
|
||||
<div
|
||||
data-alt="analyze-button"
|
||||
className="relative w-12 h-12 opacity-90 overflow-hidden rounded-xl bg-black z-10"
|
||||
>
|
||||
<div className="absolute z-10 -translate-x-12 group-hover:translate-x-12 transition-all duration-700 h-full w-12 bg-gradient-to-r from-gray-500 to-white/10 opacity-30 -skew-x-12"></div>
|
||||
<div className="absolute flex items-center justify-center text-white z-[1] opacity-90 rounded-xl inset-0.5 bg-black">
|
||||
<button
|
||||
onClick={() => {}} // 分析已经在triggerFileSelectionAndAnalyze中自动完成
|
||||
disabled={!activeImageUrl || isLoading}
|
||||
className="w-full h-full opacity-90 rounded-xl bg-black flex items-center justify-center disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="absolute duration-1000 group-hover:animate-spin w-full h-12 bg-gradient-to-r from-[rgb(106,244,249)] to-[rgb(199,59,255)] blur-[20px]"></div>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<>
|
||||
{/* Action按钮 - 使用ActionButton样式 */}
|
||||
<Tooltip
|
||||
title="Confirm story creation"
|
||||
placement="top"
|
||||
>
|
||||
<div className="relative group">
|
||||
<div
|
||||
data-alt="action-button"
|
||||
className="relative w-12 h-12 opacity-90 overflow-hidden rounded-xl bg-black z-10"
|
||||
>
|
||||
<div className="absolute z-10 -translate-x-12 group-hover:translate-x-12 transition-all duration-700 h-full w-12 bg-gradient-to-r from-gray-500 to-white/10 opacity-30 -skew-x-12"></div>
|
||||
<div className="absolute flex items-center justify-center text-white z-[1] opacity-90 rounded-xl inset-0.5 bg-black">
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
className="w-full h-full opacity-90 rounded-xl bg-black flex items-center justify-center"
|
||||
>
|
||||
<Clapperboard className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="absolute duration-1000 group-hover:animate-spin w-full h-12 bg-gradient-to-r from-[rgb(106,244,249)] to-[rgb(199,59,255)] blur-[20px]"></div>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Spin>
|
||||
</Modal>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user