video-flow-b/app/service/Interaction/ImageStoryService.ts
2025-08-17 21:30:43 +08:00

515 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
/** 故事内容用户输入或AI分析结果 */
storyContent: string;
/** 角色头像及名称数据 */
charactersAnalysis: CharacterAnalysis[];
/** 分类数据 */
potentialGenres: string[];
/** 当前选中的分类 */
selectedCategory: string;
/** 是否正在加载中(上传或分析) */
isLoading: boolean;
/** 是否已经分析过图片 */
hasAnalyzed: boolean;
/** 计算后的角色头像数据 */
avatarComputed: Array<{ name: string; url: string }>;
/** 上传图片并分析 */
uploadAndAnalyzeImage: (imageUrl: string) => Promise<void>;
/** 触发文件选择并自动分析 */
triggerFileSelectionAndAnalyze: () => Promise<void>;
/** 触发生成剧本函数 */
generateScript: () => Promise<string>;
/** 更新故事类型 */
updateStoryType: (storyType: string) => void;
/** 更新故事内容 */
updateStoryContent: (content: string) => void;
/** 更新角色名称并同步到相关数据 */
updateCharacterName: (oldName: string, newName: string) => void;
/** 重置图片故事数据 */
resetImageStory: () => void;
/** 完全重置到初始状态(包括预置数据) */
resetToInitialState: () => void;
}
export const useImageStoryServiceHook = (): UseImageStoryService => {
// 基础状态
const [imageStory, setImageStory] = useState<Partial<ImageStoryEntity>>({
imageUrl: "",
storyType: "auto",
});
// 图片相关状态
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(), []);
/**
* 根据角色区域信息生成头像URL
* @param character - 角色信息
* @param imageUrl - 源图片URL
*/
const generateAvatarFromRegion = useCallback((character: CharacterAnalysis, imageUrl: string) => {
// 创建图片对象
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) => {
// 如果已经有头像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 {
setIsLoading(true);
// 调用用例处理图片上传和分析
await imageStoryUseCase.handleImageUpload(imageUrl);
// 获取更新后的数据
const updatedStory = imageStoryUseCase.getStoryLogline();
const updatedCharacters = imageStoryUseCase.getCharactersAnalysis();
const updatedGenres = imageStoryUseCase.getPotentialGenres();
const updatedImageStory = imageStoryUseCase.getImageStory();
// 更新所有响应式状态
setCharactersAnalysis(updatedCharacters);
setPotentialGenres(updatedGenres);
setImageStory(updatedImageStory);
// 将AI分析的故事内容直接更新到统一的故事内容字段
setStoryContent(updatedStory || "");
// 设置第一个分类为默认选中
if (updatedGenres.length > 0) {
setSelectedCategory(updatedGenres[0]);
}
// 标记已分析
setHasAnalyzed(true);
} catch (error) {
console.error("图片上传分析失败:", error);
throw error;
} finally {
setIsLoading(false);
}
},
[imageStoryUseCase]
);
/**
* 触发生成剧本函数
* @returns {Promise<string>} 生成的剧本ID或内容
*/
const generateScript = useCallback(async (): Promise<string> => {
if (!activeImageUrl) {
throw new Error("请先上传图片");
}
const finalStoryContent = storyContent;
if (!finalStoryContent.trim()) {
throw new Error("请先输入或生成故事内容");
}
try {
setIsLoading(true);
// 这里可以调用后端API生成剧本
// 暂时返回一个模拟的剧本ID
const scriptId = `script_${Date.now()}`;
// TODO: 实现实际的剧本生成逻辑
// const response = await generateScriptFromImage(imageStory);
// return response.scriptId;
return scriptId;
} catch (error) {
console.error("生成剧本失败:", error);
throw error;
} finally {
setIsLoading(false);
}
}, [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);
}, []);
/**
* 更新角色名称并同步到相关数据
* @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
)
);
// 同步更新故事内容中的角色名称
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: "",
storyType: "auto",
});
setActiveImageUrl("");
setStoryContent("");
setPotentialGenres([]);
setSelectedCategory("auto");
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";
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);
}
};
fileInput.oncancel = () => {
document.body.removeChild(fileInput);
reject();
};
document.body.appendChild(fileInput);
fileInput.click();
});
}, [uploadFile, uploadAndAnalyzeImage]);
return {
imageStory,
activeImageUrl,
storyContent,
charactersAnalysis,
potentialGenres,
selectedCategory,
isLoading,
hasAnalyzed,
avatarComputed,
uploadAndAnalyzeImage,
triggerFileSelectionAndAnalyze,
generateScript,
updateStoryType,
updateStoryContent,
updateCharacterName,
resetImageStory,
resetToInitialState,
};
};