forked from 77media/video-flow
577 lines
17 KiB
TypeScript
577 lines
17 KiB
TypeScript
import { ImageStoryEntity } from "../domain/Entities";
|
||
import { useUploadFile } from "../domain/service";
|
||
import { ImageStoryUseCase } from "../usecase/imageStoryUseCase";
|
||
import {
|
||
useState,
|
||
useCallback,
|
||
useMemo,
|
||
Dispatch,
|
||
SetStateAction,
|
||
} from "react";
|
||
import {
|
||
CharacterAnalysis,
|
||
CreateMovieProjectV2Request,
|
||
CreateMovieProjectResponse,
|
||
} from "@/api/DTO/movie_start_dto";
|
||
import { MovieProjectService, MovieProjectMode } from "./MovieProjectService";
|
||
import { AspectRatioValue } from '@/components/ChatInputBox/AspectRatioSelector'
|
||
import { getClientUserData } from "@/api/common";
|
||
|
||
interface UseImageStoryService {
|
||
/** 当前图片故事数据 */
|
||
imageStory: Partial<ImageStoryEntity>;
|
||
/** 当前活跃的图片地址 */
|
||
activeImageUrl: string;
|
||
/** 故事内容(用户输入或AI分析结果) */
|
||
storyContent: string;
|
||
/** 角色头像及名称数据 */
|
||
charactersAnalysis: CharacterAnalysis[];
|
||
/** 分类数据 */
|
||
potentialGenres: string[];
|
||
/** 当前选中的分类 */
|
||
selectedCategory: string;
|
||
/** 是否正在加载中(上传或分析) */
|
||
isLoading: boolean;
|
||
/** 是否已经分析过图片 */
|
||
hasAnalyzed: boolean;
|
||
/** 计算后的角色头像数据 */
|
||
avatarComputed: Array<{ name: string; url: string }>;
|
||
/** 原始用户描述 */
|
||
originalUserDescription: string;
|
||
/** 分析任务进度 */
|
||
taskProgress: number;
|
||
/** 上传图片并分析 */
|
||
uploadAndAnalyzeImage: () => Promise<void>;
|
||
/** 触发文件选择 */
|
||
triggerFileSelection: () => Promise<void>;
|
||
/** 更新故事类型 */
|
||
updateStoryType: (storyType: string) => void;
|
||
/** 更新故事内容 */
|
||
updateStoryContent: (content: string) => void;
|
||
/** 更新角色名称并同步到相关数据 */
|
||
updateCharacterName: (oldName: string, newName: string) => void;
|
||
/** 同步角色名称到故事内容 */
|
||
syncRoleNameToContent: (oldName: string, newName: string) => void;
|
||
/** 重置图片故事数据 */
|
||
resetImageStory: (showAnalysisState?: boolean) => void;
|
||
/** 生成动作电影 */
|
||
actionMovie: (
|
||
user_id: string,
|
||
mode?: "auto" | "manual",
|
||
resolution?: "720p" | "1080p" | "4k",
|
||
language?: string,
|
||
aspectRatio?: AspectRatioValue
|
||
) => Promise<{ project_id: string } | undefined>;
|
||
/** 设置角色分析 */
|
||
setCharactersAnalysis: Dispatch<SetStateAction<CharacterAnalysis[]>>;
|
||
/** 设置原始用户描述 */
|
||
setOriginalUserDescription: Dispatch<SetStateAction<string>>;
|
||
/** 上传人物头像并分析特征,替换旧的角色数据 */
|
||
uploadCharacterAvatarAndAnalyzeFeatures: (
|
||
characterName: string
|
||
) => Promise<void>;
|
||
}
|
||
|
||
export const useImageStoryServiceHook = (): UseImageStoryService => {
|
||
// 基础状态
|
||
const [imageStory, setImageStory] = useState<Partial<ImageStoryEntity>>({
|
||
imageUrl: "",
|
||
storyType: "",
|
||
});
|
||
|
||
// 图片相关状态
|
||
const [activeImageUrl, setActiveImageUrl] = useState<string>("");
|
||
|
||
// 故事内容状态(统一管理用户输入和AI分析结果)
|
||
const [storyContent, setStoryContent] = useState<string>("");
|
||
// 原始用户描述
|
||
const [originalUserDescription, setOriginalUserDescription] =
|
||
useState<string>("");
|
||
|
||
// 分析结果状态
|
||
/** 角色头像及名称 */
|
||
const [charactersAnalysis, setCharactersAnalysis] = useState<
|
||
CharacterAnalysis[]
|
||
>([]);
|
||
/** 分类数组 */
|
||
const [potentialGenres, setPotentialGenres] = useState<string[]>([]);
|
||
|
||
// 分类状态
|
||
const [selectedCategory, setSelectedCategory] = useState<string>("");
|
||
|
||
// 流程状态
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const [hasAnalyzed, setHasAnalyzed] = useState(false);
|
||
/** 分析任务进度 */
|
||
const [taskProgress, setTaskProgress] = useState(0);
|
||
// 使用上传文件Hook
|
||
const { uploadFile } = useUploadFile();
|
||
const [taskId, setTaskId] = useState<string>("");
|
||
|
||
/** 图片故事用例实例 */
|
||
const imageStoryUseCase = useMemo(() => new ImageStoryUseCase(), []);
|
||
|
||
/**
|
||
* 根据角色区域信息生成头像URL
|
||
* @param character - 角色信息
|
||
* @param imageUrl - 源图片URL
|
||
*/
|
||
const generateAvatarFromRegion = useCallback(
|
||
(character: CharacterAnalysis, imageUrl: string) => {
|
||
if (
|
||
!character.region ||
|
||
!character.region.width ||
|
||
!character.region.height
|
||
) {
|
||
return;
|
||
}
|
||
// 创建图片对象
|
||
const img = new Image();
|
||
img.crossOrigin = "anonymous"; // 处理跨域问题
|
||
|
||
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);
|
||
|
||
// 验证裁剪区域是否有效
|
||
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) => {
|
||
console.log("character", character);
|
||
// 如果已经有头像URL,直接返回
|
||
if (character.crop_url) {
|
||
return {
|
||
name: character.role_name,
|
||
url: character.crop_url,
|
||
};
|
||
}
|
||
|
||
// // 异步生成头像URL
|
||
// generateAvatarFromRegion(character, activeImageUrl);
|
||
|
||
// return {
|
||
// name: character.role_name,
|
||
// url: "", // 初始为空,异步生成完成后会更新
|
||
// };
|
||
})
|
||
.filter(Boolean) as { name: string; url: string }[];
|
||
}, [charactersAnalysis, activeImageUrl, generateAvatarFromRegion]);
|
||
/**
|
||
* 上传图片并分析
|
||
* @param {string} imageUrl - 已上传的图片URL
|
||
*/
|
||
const uploadAndAnalyzeImage = useCallback(async (): Promise<void> => {
|
||
try {
|
||
setIsLoading(true);
|
||
setTaskProgress(1);
|
||
|
||
const setData = () => {
|
||
setOriginalUserDescription(storyContent);
|
||
// 获取更新后的数据
|
||
const updatedStory = imageStoryUseCase.storyLogline;
|
||
const updatedCharacters = imageStoryUseCase.charactersAnalysis;
|
||
const updatedGenres = imageStoryUseCase.potentialGenres;
|
||
const updatedImageStory = imageStoryUseCase.imageStory;
|
||
setSelectedCategory(imageStoryUseCase.potentialGenres[0]);
|
||
// 更新所有响应式状态
|
||
setCharactersAnalysis(updatedCharacters);
|
||
setPotentialGenres(updatedGenres);
|
||
setImageStory(updatedImageStory);
|
||
|
||
// 将AI分析的故事内容直接更新到统一的故事内容字段
|
||
updateStoryContent(updatedStory || "");
|
||
|
||
// 标记已分析
|
||
setHasAnalyzed(true);
|
||
};
|
||
|
||
// 调用用例处理图片上传和分析
|
||
const taskId = await imageStoryUseCase.handleImageUpload(activeImageUrl);
|
||
setTaskId(taskId);
|
||
for await (const result of await imageStoryUseCase.pollTaskStatus(
|
||
taskId
|
||
)) {
|
||
setTaskProgress(result.progress);
|
||
switch (result.status) {
|
||
case "submitted":
|
||
break;
|
||
case "processing":
|
||
setData();
|
||
break;
|
||
case "completed":
|
||
setData();
|
||
setHasAnalyzed(true);
|
||
setTaskProgress(0);
|
||
return
|
||
case "failed":
|
||
setHasAnalyzed(false);
|
||
setTaskProgress(0);
|
||
return
|
||
default:
|
||
break;
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error("图片上传分析失败:", error);
|
||
setHasAnalyzed(false);
|
||
|
||
throw error;
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
}, [imageStoryUseCase, activeImageUrl, storyContent]);
|
||
|
||
/**
|
||
* 更新故事类型
|
||
* @param {string} storyType - 新的故事类型
|
||
*/
|
||
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 => {
|
||
setStoryContent(content);
|
||
imageStoryUseCase.updateStoryContent(content);
|
||
},
|
||
[imageStoryUseCase]
|
||
);
|
||
|
||
/**
|
||
* 同步角色名称到故事内容
|
||
* @param {string} oldName - 旧的角色名称
|
||
* @param {string} newName - 新的角色名称
|
||
*/
|
||
const syncRoleNameToContent = useCallback(
|
||
(oldName: string, newName: string) => {
|
||
// 更新故事内容中的角色标签
|
||
setStoryContent((prev) => {
|
||
// 匹配新的角色标签格式 <role id="C1">Dezhong Huang</role>
|
||
const regex = new RegExp(`<role[^>]*>${oldName}<\/role>`, "g");
|
||
const content = prev.replace(regex, `<role >${newName}</role>`);
|
||
imageStoryUseCase.updateStoryContent(content);
|
||
return content;
|
||
});
|
||
},
|
||
[imageStoryUseCase]
|
||
);
|
||
|
||
/**
|
||
* 更新角色名称并同步到相关数据
|
||
* @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
|
||
)
|
||
);
|
||
|
||
// 同步更新故事内容中的角色名称
|
||
syncRoleNameToContent(oldName, newName);
|
||
},
|
||
[syncRoleNameToContent]
|
||
);
|
||
|
||
/**
|
||
* 重置图片故事数据
|
||
*/
|
||
const resetImageStory = useCallback((): void => {
|
||
imageStoryUseCase.resetImageStory();
|
||
|
||
// 清理生成的头像URL,避免内存泄漏
|
||
setCharactersAnalysis([]);
|
||
|
||
// 重置所有状态
|
||
setImageStory({
|
||
imageUrl: "",
|
||
storyType: "",
|
||
});
|
||
setActiveImageUrl("");
|
||
updateStoryContent("");
|
||
setPotentialGenres([]);
|
||
setSelectedCategory("");
|
||
setHasAnalyzed(false);
|
||
setIsLoading(false);
|
||
setOriginalUserDescription("");
|
||
}, [imageStoryUseCase]);
|
||
|
||
/**
|
||
* 触发文件选择并自动分析
|
||
*/
|
||
const triggerFileSelection = 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]) {
|
||
setIsLoading(true);
|
||
|
||
// 使用传入的文件上传函数
|
||
const uploadedImageUrl = await uploadFile(
|
||
target.files[0],
|
||
(progress) => {
|
||
console.log("上传进度:", progress);
|
||
}
|
||
);
|
||
|
||
// 设置图片URL
|
||
setActiveImageUrl(uploadedImageUrl);
|
||
setImageStory((prev) => ({
|
||
...prev,
|
||
imageUrl: uploadedImageUrl,
|
||
}));
|
||
}
|
||
resolve();
|
||
} catch (error) {
|
||
reject(error);
|
||
} finally {
|
||
setIsLoading(false);
|
||
// 清理DOM
|
||
document.body.removeChild(fileInput);
|
||
}
|
||
};
|
||
|
||
fileInput.oncancel = () => {
|
||
document.body.removeChild(fileInput);
|
||
reject();
|
||
};
|
||
|
||
document.body.appendChild(fileInput);
|
||
fileInput.click();
|
||
});
|
||
}, [uploadFile]);
|
||
|
||
const actionMovie = useCallback(
|
||
async (
|
||
user_id: string,
|
||
mode: "auto" | "manual" = "auto",
|
||
resolution: "720p" | "1080p" | "4k" = "720p",
|
||
language: string = "English",
|
||
aspectRatio?: AspectRatioValue
|
||
) => {
|
||
try {
|
||
if (hasAnalyzed) {
|
||
// 从charactersAnalysis中提取whisk_caption字段组成数组
|
||
const character_briefs = charactersAnalysis.map((char) => {
|
||
return {
|
||
name: char.role_name,
|
||
image_url: char.crop_url,
|
||
character_analysis: char.role_name+":"+JSON.parse(char.whisk_caption)
|
||
?.character_analysis?.brief,
|
||
};
|
||
});
|
||
|
||
const params: CreateMovieProjectV2Request = {
|
||
script: storyContent,
|
||
user_id,
|
||
user_data: getClientUserData(),
|
||
mode,
|
||
resolution,
|
||
genre: selectedCategory,
|
||
character_briefs,
|
||
language,
|
||
image_url: activeImageUrl,
|
||
project_id:taskId,
|
||
...(aspectRatio ? { aspect_ratio: aspectRatio } : {})
|
||
};
|
||
|
||
// 调用create_movie_project_v2接口
|
||
const result = await MovieProjectService.createProject(
|
||
MovieProjectMode.IMAGE,
|
||
params
|
||
);
|
||
return { project_id: result.project_id };
|
||
}
|
||
} catch (error) {
|
||
console.error("创建电影项目失败:", error);
|
||
throw error;
|
||
}
|
||
},
|
||
[
|
||
hasAnalyzed,
|
||
storyContent,
|
||
charactersAnalysis,
|
||
selectedCategory,
|
||
activeImageUrl,
|
||
]
|
||
);
|
||
|
||
/**
|
||
* 上传人物头像并分析特征,替换旧的角色数据
|
||
* @param {string} characterName - 角色名称
|
||
*/
|
||
const uploadCharacterAvatarAndAnalyzeFeatures = useCallback(
|
||
async (characterName: string): Promise<void> => {
|
||
try {
|
||
setIsLoading(true);
|
||
|
||
// 调用用例处理人物头像上传和特征分析
|
||
const result =
|
||
await imageStoryUseCase.uploadCharacterAvatarAndAnalyzeFeatures(
|
||
uploadFile
|
||
);
|
||
|
||
// 用新的头像和特征描述替换旧的角色数据
|
||
setCharactersAnalysis((prev) =>
|
||
prev.map((char) =>
|
||
char.role_name === characterName
|
||
? {
|
||
...char,
|
||
crop_url: result.crop_url,
|
||
whisk_caption: result.whisk_caption,
|
||
}
|
||
: char
|
||
)
|
||
);
|
||
|
||
console.log("人物头像和特征描述更新成功:", result);
|
||
} catch (error) {
|
||
console.error("人物头像上传和特征分析失败:", error);
|
||
throw error;
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
},
|
||
[imageStoryUseCase, uploadFile]
|
||
);
|
||
|
||
return {
|
||
imageStory,
|
||
activeImageUrl,
|
||
storyContent,
|
||
charactersAnalysis,
|
||
potentialGenres,
|
||
selectedCategory,
|
||
isLoading,
|
||
hasAnalyzed,
|
||
avatarComputed,
|
||
originalUserDescription,
|
||
taskProgress,
|
||
setCharactersAnalysis,
|
||
uploadAndAnalyzeImage,
|
||
triggerFileSelection,
|
||
updateStoryType,
|
||
updateStoryContent,
|
||
updateCharacterName,
|
||
syncRoleNameToContent,
|
||
resetImageStory,
|
||
setOriginalUserDescription,
|
||
actionMovie,
|
||
uploadCharacterAvatarAndAnalyzeFeatures,
|
||
};
|
||
};
|