一个阶段

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

View File

@ -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,
};
};

View File

@ -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;
};
}[];
}
/**
*

View File

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

View File

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