一个阶段

This commit is contained in:
海龙 2025-08-17 21:30:43 +08:00
parent 09fc676a35
commit 76169e790d
6 changed files with 1076 additions and 386 deletions

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

View File

@ -1,4 +1,5 @@
import { ApiResponse } from "./common"; import { ApiResponse } from "./common";
import { MovieStartDTO } from "./DTO/movie_start_dto";
import { get, post } from "./request"; import { get, post } from "./request";
import { import {
StoryTemplateEntity, StoryTemplateEntity,
@ -25,9 +26,12 @@ export const actionTemplateStory = async (template: StoryTemplateEntity) => {
/** /**
* AI分析图片 * AI分析图片
*/ */
export const AIGenerateImageStory = async (imageStory: ImageStoryEntity) => { export const AIGenerateImageStory = async (request: {
return await post<ApiResponse<{ imageAnalysis: string; category: string }>>( imageUrl: string;
"/image-story/ai-generate", user_text: string;
imageStory }) => {
return await post<ApiResponse<MovieStartDTO>>(
"/movie_story/generate",
request
); );
}; };

View File

@ -2,22 +2,27 @@ import { ImageStoryEntity } from "../domain/Entities";
import { useUploadFile } from "../domain/service"; import { useUploadFile } from "../domain/service";
import { ImageStoryUseCase } from "../usecase/imageStoryUseCase"; import { ImageStoryUseCase } from "../usecase/imageStoryUseCase";
import { useState, useCallback, useMemo } from "react"; import { useState, useCallback, useMemo } from "react";
import { CharacterAnalysis } from "@/api/DTO/movie_start_dto";
interface UseImageStoryService { interface UseImageStoryService {
/** 当前图片故事数据 */ /** 当前图片故事数据 */
imageStory: Partial<ImageStoryEntity>; imageStory: Partial<ImageStoryEntity>;
/** 当前活跃的图片地址 */ /** 当前活跃的图片地址 */
activeImageUrl: string; activeImageUrl: string;
/** 分析故事结果内容 */ /** 故事内容用户输入或AI分析结果 */
analyzedStoryContent: string; storyContent: string;
/** 角色头像及名称数据 */
charactersAnalysis: CharacterAnalysis[];
/** 分类数据 */
potentialGenres: string[];
/** 当前选中的分类 */ /** 当前选中的分类 */
selectedCategory: string; selectedCategory: string;
/** 是否正在分析图片 */ /** 是否正在加载中(上传或分析) */
isAnalyzing: boolean; isLoading: boolean;
/** 是否正在上传 */ /** 是否已经分析过图片 */
isUploading: boolean; hasAnalyzed: boolean;
/** 故事类型选项 */ /** 计算后的角色头像数据 */
storyTypeOptions: Array<{ key: string; label: string }>; avatarComputed: Array<{ name: string; url: string }>;
/** 上传图片并分析 */ /** 上传图片并分析 */
uploadAndAnalyzeImage: (imageUrl: string) => Promise<void>; uploadAndAnalyzeImage: (imageUrl: string) => Promise<void>;
/** 触发文件选择并自动分析 */ /** 触发文件选择并自动分析 */
@ -28,68 +33,242 @@ interface UseImageStoryService {
updateStoryType: (storyType: string) => void; updateStoryType: (storyType: string) => void;
/** 更新故事内容 */ /** 更新故事内容 */
updateStoryContent: (content: string) => void; updateStoryContent: (content: string) => void;
/** 更新角色名称并同步到相关数据 */
updateCharacterName: (oldName: string, newName: string) => void;
/** 重置图片故事数据 */ /** 重置图片故事数据 */
resetImageStory: () => void; resetImageStory: () => void;
/** 完全重置到初始状态(包括预置数据) */
resetToInitialState: () => void;
} }
export const useImageStoryServiceHook = ( export const useImageStoryServiceHook = (): UseImageStoryService => {
): UseImageStoryService => { // 基础状态
const [imageStory, setImageStory] = useState<Partial<ImageStoryEntity>>({ const [imageStory, setImageStory] = useState<Partial<ImageStoryEntity>>({
imageUrl: "", imageUrl: "",
imageStory: "",
storyType: "auto", 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 // 使用上传文件Hook
const { uploadFile } = useUploadFile(); const { uploadFile } = useUploadFile();
/** 图片故事用例实例 */ /** 图片故事用例实例 */
const imageStoryUseCase = useMemo(() => new ImageStoryUseCase(), []); 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'; // 处理跨域问题
/** 分析故事结果内容 */ img.onload = () => {
const [analyzedStoryContent, setAnalyzedStoryContent] = useState<string>(""); 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 * @param {string} imageUrl - URL
*/ */
const uploadAndAnalyzeImage = useCallback(async (imageUrl: string): Promise<void> => { const uploadAndAnalyzeImage = useCallback(
async (imageUrl: string): Promise<void> => {
try { try {
setIsUploading(true); setIsLoading(true);
setIsAnalyzing(true);
// 调用用例处理图片上传和分析 // 调用用例处理图片上传和分析
await imageStoryUseCase.handleImageUpload(imageUrl); await imageStoryUseCase.handleImageUpload(imageUrl);
// 获取更新后的数据 // 获取更新后的数据
const updatedStory = imageStoryUseCase.getImageStory(); const updatedStory = imageStoryUseCase.getStoryLogline();
setImageStory(updatedStory); const updatedCharacters = imageStoryUseCase.getCharactersAnalysis();
const updatedGenres = imageStoryUseCase.getPotentialGenres();
const updatedImageStory = imageStoryUseCase.getImageStory();
// 更新活跃状态 // 更新所有响应式状态
setActiveImageUrl(imageUrl); setCharactersAnalysis(updatedCharacters);
setAnalyzedStoryContent(updatedStory.imageStory || ""); setPotentialGenres(updatedGenres);
setSelectedCategory(updatedStory.storyType || "auto"); setImageStory(updatedImageStory);
// 将AI分析的故事内容直接更新到统一的故事内容字段
setStoryContent(updatedStory || "");
// 设置第一个分类为默认选中
if (updatedGenres.length > 0) {
setSelectedCategory(updatedGenres[0]);
}
// 标记已分析
setHasAnalyzed(true);
} catch (error) { } catch (error) {
console.error('图片上传分析失败:', error); console.error("图片上传分析失败:", error);
throw error; throw error;
} finally { } finally {
setIsUploading(false); setIsLoading(false);
setIsAnalyzing(false);
} }
}, [imageStoryUseCase]); },
[imageStoryUseCase]
);
/** /**
* *
@ -97,15 +276,16 @@ export const useImageStoryServiceHook = (
*/ */
const generateScript = useCallback(async (): Promise<string> => { const generateScript = useCallback(async (): Promise<string> => {
if (!activeImageUrl) { if (!activeImageUrl) {
throw new Error('请先上传图片'); throw new Error("请先上传图片");
} }
if (!analyzedStoryContent) { const finalStoryContent = storyContent;
throw new Error('请先输入或生成故事内容'); if (!finalStoryContent.trim()) {
throw new Error("请先输入或生成故事内容");
} }
try { try {
setIsAnalyzing(true); setIsLoading(true);
// 这里可以调用后端API生成剧本 // 这里可以调用后端API生成剧本
// 暂时返回一个模拟的剧本ID // 暂时返回一个模拟的剧本ID
@ -117,59 +297,150 @@ export const useImageStoryServiceHook = (
return scriptId; return scriptId;
} catch (error) { } catch (error) {
console.error('生成剧本失败:', error); console.error("生成剧本失败:", error);
throw error; throw error;
} finally { } finally {
setIsAnalyzing(false); setIsLoading(false);
} }
}, [activeImageUrl, analyzedStoryContent, imageStory]); }, [activeImageUrl, storyContent]);
/** /**
* *
* @param {string} storyType - * @param {string} storyType -
*/ */
const updateStoryType = useCallback((storyType: string): void => { const updateStoryType = useCallback(
(storyType: string): void => {
imageStoryUseCase.updateStoryType(storyType); imageStoryUseCase.updateStoryType(storyType);
setImageStory(prev => ({ ...prev, storyType })); setImageStory((prev) => ({ ...prev, storyType }));
setSelectedCategory(storyType); setSelectedCategory(storyType);
}, [imageStoryUseCase]); },
[imageStoryUseCase]
);
/** /**
* *
* @param {string} content - * @param {string} content -
*/ */
const updateStoryContent = useCallback((content: string): void => { const updateStoryContent = useCallback((content: string): void => {
imageStoryUseCase.updateStoryContent(content); setStoryContent(content);
setImageStory(prev => ({ ...prev, imageStory: content })); }, []);
setAnalyzedStoryContent(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 => { const resetImageStory = useCallback((): void => {
imageStoryUseCase.resetImageStory(); imageStoryUseCase.resetImageStory();
// 清理生成的头像URL避免内存泄漏
setCharactersAnalysis(prev => {
prev.forEach(char => {
if (char.avatarUrl) {
URL.revokeObjectURL(char.avatarUrl);
}
});
return [];
});
// 重置所有状态
setImageStory({ setImageStory({
imageUrl: "", imageUrl: "",
imageStory: "",
storyType: "auto", storyType: "auto",
}); });
// 重置活跃状态
setActiveImageUrl(""); setActiveImageUrl("");
setAnalyzedStoryContent(""); setStoryContent("");
setPotentialGenres([]);
setSelectedCategory("auto"); setSelectedCategory("auto");
setIsAnalyzing(false); setHasAnalyzed(false);
setIsUploading(false); setIsLoading(false);
}, [imageStoryUseCase]); }, [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> => { const triggerFileSelectionAndAnalyze =
useCallback(async (): Promise<void> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// 创建文件输入元素 // 创建文件输入元素
const fileInput = document.createElement("input"); const fileInput = document.createElement("input");
@ -181,18 +452,31 @@ export const useImageStoryServiceHook = (
try { try {
const target = e.target as HTMLInputElement; const target = e.target as HTMLInputElement;
if (target.files && target.files[0]) { if (target.files && target.files[0]) {
setIsLoading(true);
// 使用传入的文件上传函数 // 使用传入的文件上传函数
const uploadedImageUrl = await uploadFile(target.files[0], (progress) => { const uploadedImageUrl = await uploadFile(
target.files[0],
(progress) => {
console.log("上传进度:", progress); console.log("上传进度:", progress);
}); }
console.log('uploadedImageUrl', uploadedImageUrl) );
// await uploadAndAnalyzeImage(uploadedImageUrl);
// 设置图片URL
setActiveImageUrl(uploadedImageUrl); setActiveImageUrl(uploadedImageUrl);
setImageStory((prev) => ({
...prev,
imageUrl: uploadedImageUrl,
}));
// 自动开始分析
await uploadAndAnalyzeImage(uploadedImageUrl);
} }
resolve(); resolve();
} catch (error) { } catch (error) {
reject(error); reject(error);
} finally { } finally {
setIsLoading(false);
// 清理DOM // 清理DOM
document.body.removeChild(fileInput); document.body.removeChild(fileInput);
} }
@ -206,21 +490,25 @@ export const useImageStoryServiceHook = (
document.body.appendChild(fileInput); document.body.appendChild(fileInput);
fileInput.click(); fileInput.click();
}); });
}, [uploadFile]); }, [uploadFile, uploadAndAnalyzeImage]);
return { return {
imageStory, imageStory,
activeImageUrl, activeImageUrl,
analyzedStoryContent, storyContent,
charactersAnalysis,
potentialGenres,
selectedCategory, selectedCategory,
isAnalyzing, isLoading,
isUploading, hasAnalyzed,
storyTypeOptions, avatarComputed,
uploadAndAnalyzeImage, uploadAndAnalyzeImage,
triggerFileSelectionAndAnalyze, triggerFileSelectionAndAnalyze,
generateScript, generateScript,
updateStoryType, updateStoryType,
updateStoryContent, updateStoryContent,
updateCharacterName,
resetImageStory, resetImageStory,
resetToInitialState,
}; };
}; };

View File

@ -96,6 +96,24 @@ export interface ImageStoryEntity {
imageAnalysis: string; imageAnalysis: string;
/** 故事分类 */ /** 故事分类 */
storyType: string; storyType: string;
/**角色头像 */
roleImage: {
/** 名称 */
name: string;
/** 头像URL本地create的url */
avatar_url: string;
/** 角色区域 小数比例形式 */
region: {
/** x坐标 */
x: number;
/** y坐标 */
y: number;
/** 宽度 */
width: number;
/** 高度 */
height: number;
};
}[];
} }
/** /**
* *

View File

@ -1,5 +1,6 @@
import { ImageStoryEntity } from "../domain/Entities"; import { ImageStoryEntity } from "../domain/Entities";
import { AIGenerateImageStory } from "@/api/movie_start"; import { AIGenerateImageStory } from "@/api/movie_start";
import { MovieStartDTO, CharacterAnalysis } from "@/api/DTO/movie_start_dto";
/** /**
* *
@ -7,17 +8,26 @@ import { AIGenerateImageStory } from "@/api/movie_start";
*/ */
export class ImageStoryUseCase { export class ImageStoryUseCase {
/** 当前图片故事数据 */ /** 当前图片故事数据 */
private imageStory: Partial<ImageStoryEntity> = { imageStory: Partial<ImageStoryEntity> = {
imageUrl: "", imageUrl: "",
imageStory: "", imageStory: "",
storyType: "auto", storyType: "auto",
}; };
/** 故事梗概 */
storyLogline: string = "";
/** 角色头像及名称数据 */
charactersAnalysis: CharacterAnalysis[] = [];
/** 分类数据 */
potentialGenres: string[] = [];
/** 是否正在分析图片 */ /** 是否正在分析图片 */
private isAnalyzing: boolean = false; isAnalyzing: boolean = false;
/** 是否正在上传 */ /** 是否正在上传 */
private isUploading: boolean = false; isUploading: boolean = false;
constructor() {} constructor() {}
@ -29,6 +39,30 @@ export class ImageStoryUseCase {
return { ...this.imageStory }; 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} * @returns {boolean}
@ -62,6 +96,9 @@ export class ImageStoryUseCase {
imageStory: "", imageStory: "",
storyType: "auto", storyType: "auto",
}; };
this.storyLogline = "";
this.charactersAnalysis = [];
this.potentialGenres = [];
this.isAnalyzing = false; this.isAnalyzing = false;
this.isUploading = false; this.isUploading = false;
} }
@ -96,20 +133,20 @@ export class ImageStoryUseCase {
* 使AI分析图片 * 使AI分析图片
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
private async analyzeImageWithAI(): Promise<void> { async analyzeImageWithAI(): Promise<void> {
try { try {
// 调用AI分析接口 // 调用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) { if (response.successful && response.data) {
const { imageAnalysis, category } = response.data; // 解析并存储新的数据结构
this.parseAndStoreAnalysisData(response.data);
// 更新分析结果和分类 // 组合成ImageStoryEntity
this.setImageStory({ this.composeImageStoryEntity(response.data);
imageAnalysis,
storyType: category || "auto",
imageStory: imageAnalysis, // 将AI分析结果作为默认故事内容
});
} else { } else {
throw new Error("AI分析失败"); 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 - * @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 }> { getStoryTypeOptions(): Array<{ key: string; label: string }> {
return [ return [
@ -149,5 +226,53 @@ export class ImageStoryUseCase {
{ key: "comedy", label: "Comedy" }, { 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);
}
} }

View File

@ -1,12 +1,6 @@
"use client"; "use client";
import { import { useState, useRef, useEffect } from "react";
useState,
useRef,
useEffect,
forwardRef,
useImperativeHandle,
} from "react";
import { import {
ChevronDown, ChevronDown,
ChevronUp, ChevronUp,
@ -23,8 +17,11 @@ import {
Plus, Plus,
LayoutTemplate, LayoutTemplate,
ImagePlay, ImagePlay,
Sparkles,
RotateCcw,
Settings,
} from "lucide-react"; } 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 { PlusOutlined, UploadOutlined, EyeOutlined } from "@ant-design/icons";
import { StoryTemplateEntity } from "@/app/service/domain/Entities"; import { StoryTemplateEntity } from "@/app/service/domain/Entities";
import { useImageStoryServiceHook } from "@/app/service/Interaction/ImageStoryService"; import { useImageStoryServiceHook } from "@/app/service/Interaction/ImageStoryService";
@ -133,6 +130,14 @@ const customAudioPlayerStyles = `
.scale-102 { .scale-102 {
transform: scale(1.02); 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 [isTemplateModalOpen, setIsTemplateModalOpen] = useState(false);
// 图片故事模式状态 // 图片故事弹窗状态
const [isPhotoStoryMode, setIsPhotoStoryMode] = useState(false); const [isPhotoStoryModalOpen, setIsPhotoStoryModalOpen] = useState(false);
// 共享状态 - 需要在不同渲染函数间共享 // 共享状态 - 需要在不同渲染函数间共享
const [script, setScript] = useState(""); // 用户输入的脚本内容 const [script, setScript] = useState(""); // 用户输入的脚本内容
const router = useRouter(); 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 [loadingIdea, setLoadingIdea] = useState(false); // 获取创意建议时的加载状态
const [isCreating, setIsCreating] = useState(false); // 视频创建过程中的加载状态 const [isCreating, setIsCreating] = useState(false); // 视频创建过程中的加载状态
@ -522,10 +509,8 @@ export function ChatInputBox() {
videoDuration: "1min", videoDuration: "1min",
}); });
// 退出图片故事模式 // 配置项显示控制状态
const exitPhotoStoryMode = () => { const [showConfigOptions, setShowConfigOptions] = useState(false);
setIsPhotoStoryMode(false);
};
const handleGetIdea = () => { const handleGetIdea = () => {
if (loadingIdea) return; if (loadingIdea) return;
@ -538,22 +523,14 @@ export function ChatInputBox() {
}, 3000); }, 3000);
}; };
// 当进入图片故事模式时,调用子组件方法
const enterPhotoStoryMode = () => {
setIsPhotoStoryMode(true);
// 调用子组件的进入方法
if (photoStoryModeRef.current) {
photoStoryModeRef.current.enterPhotoStoryMode();
}
};
// Handle creating video // Handle creating video
const handleCreateVideo = async () => { const handleCreateVideo = async () => {
setIsCreating(true); setIsCreating(true);
if (!script) {
setIsCreating(false);
return;
}
const User = JSON.parse(localStorage.getItem("currentUser") || "{}"); const User = JSON.parse(localStorage.getItem("currentUser") || "{}");
// 创建剧集数据 // 创建剧集数据
@ -563,12 +540,12 @@ export function ChatInputBox() {
mode: configOptions.mode, mode: configOptions.mode,
resolution: configOptions.resolution, resolution: configOptions.resolution,
language: configOptions.language, language: configOptions.language,
video_duration: configOptions.videoDuration video_duration: configOptions.videoDuration,
}; };
// 调用创建剧集API // 调用创建剧集API
const episodeResponse = await createScriptEpisodeNew(episodeData); const episodeResponse = await createScriptEpisodeNew(episodeData);
console.log('episodeResponse', episodeResponse); console.log("episodeResponse", episodeResponse);
if (episodeResponse.code !== 0) { if (episodeResponse.code !== 0) {
console.error(`创建剧集失败: ${episodeResponse.message}`); console.error(`创建剧集失败: ${episodeResponse.message}`);
alert(`创建剧集失败: ${episodeResponse.message}`); alert(`创建剧集失败: ${episodeResponse.message}`);
@ -578,7 +555,7 @@ export function ChatInputBox() {
// let episodeId = '9c34fcc4-c8d8-44fc-879e-9bd56f608c76'; // let episodeId = '9c34fcc4-c8d8-44fc-879e-9bd56f608c76';
router.push(`/create/work-flow?episodeId=${episodeId}`); router.push(`/create/work-flow?episodeId=${episodeId}`);
setIsCreating(false); setIsCreating(false);
} };
return ( return (
<div className="video-tool-component relative max-w-[1080px] w-full left-[50%] translate-x-[-50%]"> <div className="video-tool-component relative max-w-[1080px] w-full left-[50%] translate-x-[-50%]">
@ -606,138 +583,148 @@ export function ChatInputBox() {
{/* 主要内容区域 - 简化层级,垂直居中 */} {/* 主要内容区域 - 简化层级,垂直居中 */}
<div <div
className={`flex items-center justify-center p-1 transition-all duration-300 ${ data-alt="+ ---------"
isExpanded ? "h-[16px]" : "h-[60px]" className={`flex items-center justify-center p-1 transition-all duration-300 relative ${
isExpanded ? "h-[16px]" : "h-auto"
}`} }`}
> >
{/* 输入框和Action按钮 - 只在展开状态显示 */} {/* 右上角齿轮图标和配置项 */}
{!isExpanded && ( {!isExpanded && (
<div className="flex items-center gap-3 w-full pl-3"> <div className="absolute top-2 right-2 z-10 flex items-center">
{/* 模式选择下拉菜单 */} {/* 配置项显示区域 - 从齿轮图标右侧边缘滑入 */}
<Dropdown <div
menu={{ 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 ${
items: [ showConfigOptions
{ ? " opacity-100"
key: "template", : "opacity-0 pointer-events-none"
label: ( }`}
<div className="flex items-center gap-3 px-3 py-2"> style={{
<LayoutTemplate className="w-5 h-5 text-white/80" /> transformOrigin: "left center",
<span className="text-sm text-white/90"> willChange: "transform, opacity",
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);
},
}} }}
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>
)}
</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>
<div className="absolute flex items-center justify-center text-white z-[1] opacity-90 rounded-2xl inset-0.5 bg-black">
<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}
>
{isCreating ? (
<>
<Loader2 className="w-3 h-3 animate-spin" />
Actioning...
</>
) : (
<>
<Clapperboard className="w-5 h-5" />
Action
</>
)}
</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>
</div>
</div>
</div>
)}
</div>
</div>
{/* 配置选项区域 */}
<ConfigOptions <ConfigOptions
config={configOptions} config={configOptions}
onConfigChange={(key, value) => onConfigChange={(key, value) =>
setConfigOptions((prev) => ({ ...prev, [key]: value })) setConfigOptions((prev) => ({ ...prev, [key]: value }))
} }
compact={true}
/> />
</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>
)}
{/* 输入框和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
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()}
>
{loadingIdea ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Lightbulb className="w-4 h-4" />
)}
</button>
</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
config={configOptions}
onConfigChange={(key, value) =>
setConfigOptions((prev) => ({ ...prev, [key]: value }))
}
/> */}
{/* 模板故事弹窗 */} {/* 模板故事弹窗 */}
<RenderTemplateStoryMode <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 = ({ const ConfigOptions = ({
config, config,
onConfigChange, onConfigChange,
compact = false,
}: { }: {
config: { config: {
mode: string; mode: string;
@ -763,6 +785,7 @@ const ConfigOptions = ({
videoDuration: string; videoDuration: string;
}; };
onConfigChange: (key: string, value: string) => void; onConfigChange: (key: string, value: string) => void;
compact?: boolean;
}) => { }) => {
const configItems = [ const configItems = [
{ {
@ -805,13 +828,12 @@ const ConfigOptions = ({
]; ];
return ( 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) => { {configItems.map((item) => {
const IconComponent = item.icon; const IconComponent = item.icon;
const currentOption = item.options.find( const currentOption = item.options.find(
(opt) => opt.value === config[item.key as keyof typeof config] (opt) => opt.value === config[item.key as keyof typeof config]
); );
return ( return (
<Dropdown <Dropdown
key={item.key} key={item.key}
@ -834,12 +856,22 @@ const ConfigOptions = ({
> >
<button <button
data-alt={`config-${item.key}`} 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" /> <IconComponent
<span className="text-xs">{currentOption?.label}</span> className={`${compact ? "w-3 h-3" : "w-3.5 h-3.5"}`}
/>
<span className={`${compact ? "text-xs" : "text-xs"}`}>
{currentOption?.label}
</span>
{currentOption?.isVip && ( {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> </button>
</Dropdown> </Dropdown>
@ -850,128 +882,308 @@ const ConfigOptions = ({
}; };
/** /**
* *
* * AI分析和故事生成功能UI变化
* 使hook管理状态ref暴露方法给父组件
*/ */
const PhotoStoryMode = forwardRef< const PhotoStoryModal = ({
{ isOpen,
enterPhotoStoryMode: () => void; onClose,
getStoryContent: () => string; onConfirm,
}, }: {
{ isOpen: boolean;
onExitMode: () => void; onClose: () => void;
isVisible: boolean; onConfirm: (storyContent: string, category: string) => void;
} }) => {
>(({ onExitMode, isVisible }, ref) => { // 使用图片故事服务hook管理状态
// 使用图片故事服务hook管理自己的状态
const { const {
activeImageUrl, activeImageUrl,
storyContent,
charactersAnalysis,
potentialGenres,
selectedCategory, selectedCategory,
isAnalyzing, isLoading,
isUploading, hasAnalyzed,
storyTypeOptions,
analyzedStoryContent,
updateStoryType, updateStoryType,
resetImageStory,
updateStoryContent, updateStoryContent,
updateCharacterName,
resetImageStory,
resetToInitialState,
triggerFileSelectionAndAnalyze, triggerFileSelectionAndAnalyze,
avatarComputed,
} = useImageStoryServiceHook(); } = useImageStoryServiceHook();
// 通过ref暴露方法给父组件 // 重置状态
useImperativeHandle( const handleClose = () => {
ref,
() => ({
enterPhotoStoryMode: () => {
// 触发文件选择和分析
triggerFileSelectionAndAnalyze().catch((error: unknown) => {
console.error("Failed to enter photo story mode:", error);
});
},
getStoryContent: () => analyzedStoryContent || "",
}),
[triggerFileSelectionAndAnalyze, analyzedStoryContent]
);
// 处理退出模式
const handleExitMode = () => {
resetImageStory(); resetImageStory();
onExitMode(); onClose();
}; };
// 如果不显示返回null // 处理图片上传
if (!isVisible) { const handleImageUpload = async () => {
return null; try {
await triggerFileSelectionAndAnalyze();
} catch (error) {
console.error("Failed to upload image:", error);
} }
};
// 处理确认
const handleConfirm = () => {
if (storyContent.trim()) {
onConfirm(storyContent, selectedCategory);
}
};
return ( return (
<div className="absolute top-[-110px] left-14 right-0 flex items-center justify-between"> <Modal
{/* 左侧:图片预览区域和分析状态指示器 */} open={isOpen}
<div className="flex items-center gap-3"> onCancel={handleClose}
{/* 图片预览区域 - 使用Ant Design Image组件 */} footer={null}
{activeImageUrl && ( width="80%"
<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)]"> style={{ maxWidth: "1000px" }}
<Image 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>
<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} src={activeImageUrl}
alt="Story inspiration" alt="Story inspiration"
className="w-full h-full object-cover" className="w-full h-full object-cover rounded-lg"
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> <Tooltip title="Clear all content !!! " placement="top">
)}
{/* 删除图片按钮 - 简洁样式 */}
{activeImageUrl && (
<button <button
onClick={handleExitMode} onClick={(e) => {
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" e.stopPropagation();
title="删除图片并退出图片故事模式" 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" /> <Trash2 className="w-2.5 h-2.5" />
</button> </button>
)} </Tooltip>
</div>
{/* 分析状态指示器 */} ) : (
{isAnalyzing && ( <div className="text-center text-white/60">
<div className="flex items-center gap-2 px-3 py-2 bg-white/[0.1] rounded-lg"> <Upload className="w-6 h-6 mx-auto mb-1 opacity-50" />
<Loader2 className="w-4 h-4 animate-spin text-white/80" /> <p className="text-xs">Upload</p>
<span className="text-sm text-white/80">
{isUploading ? "Uploading image..." : "Analyzing image..."}
</span>
</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}
</div> </div>
),
})), {/* 中间:头像展示(分析后显示) */}
onClick: ({ key }) => updateStoryType(key), {hasAnalyzed && avatarComputed.length > 0 && (
}} <div className="flex-1 animate-in fade-in-0 slide-in-from-left-4 duration-300">
trigger={["click"]} <div className="flex gap-2 n justify-start">
placement="bottomRight" {avatarComputed.map((avatar, index) => (
<div
key={`${avatar.name}-${index}`}
className="flex flex-col items-center"
> >
<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"> <div className="relative w-14 h-14 rounded-sm overflow-hidden bg-white/[0.05] border border-white/[0.1] mb-2 group">
<span> <img
{storyTypeOptions.find((t) => t.key === selectedCategory) src={avatar.url}
?.label || "Auto"} alt={avatar.name}
</span> className="w-full h-full object-cover"
<ChevronDown className="w-3 h-3" /> 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> </button>
</Dropdown> </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>
)}
{/* 右侧:分类选择(分析后显示) */}
{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> </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>
); );
}); };