2025-08-19 03:20:18 +08:00

591 lines
18 KiB
TypeScript
Raw Permalink 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 { useState, useCallback, useEffect, useRef } from "react";
import {
VideoSegmentEditUseCase,
} from "../usecase/ShotEditUsecase";
import {
MatchedPerson,
RoleRecognitionResponse
} from "@/api/DTO/movieEdit";
import { ScriptRoleEntity, VideoSegmentEntity } from "../domain/Entities";
import { LensType, SimpleCharacter } from "../domain/valueObject";
import { getUploadToken, uploadToQiniu } from "@/api/common";
import { SaveEditUseCase } from "../usecase/SaveEditUseCase";
import { getNewShotVideo } from "@/api/video_flow";
/**
* 视频片段服务Hook接口
* 定义视频片段服务Hook的所有状态和操作方法
*/
export interface UseShotService {
// 响应式状态
/** 加载状态 */
loading: boolean;
/** 视频片段列表 */
videoSegments: VideoSegmentEntity[];
/** 剧本中角色列表 */
scriptRoles: ScriptRoleEntity[];
/** 当前选中的视频片段 */
selectedSegment: VideoSegmentEntity | null;
/** 识别出的人物信息 */
matched_persons: MatchedPerson[];
// 操作方法
/** 获取视频片段列表 */
getVideoSegmentList: (projectId: string) => Promise<void>;
/** 重新生成视频片段 */
regenerateVideoSegment: () => Promise<VideoSegmentEntity>;
/** AI优化视频内容 */
optimizeVideoContent: (
shotId: string,
userRequirement: string,
lensData: LensType[]
) => Promise<LensType[]>;
/** 中断当前操作 */
abortOperation: () => void;
/** 设置选中的视频片段 */
setSelectedSegment: (segment: VideoSegmentEntity | null) => void;
/** 添加新镜头到选中的视频片段 */
addNewLens: () => void;
/** 删除指定镜头 */
deleteLens: (lensName: string) => void;
/** 获取视频当前帧并上传到七牛云 */
filterRole: (
video: HTMLVideoElement
) => Promise<RoleRecognitionResponse | undefined>;
/** 设置角色简单数据 */
setSimpleCharacter: (characters: SimpleCharacter[]) => void;
/** 计算识别框 */
calculateRecognitionBoxes: (containerElement: HTMLElement, matched_persons: MatchedPerson[]) => Array<{
left: number;
top: number;
width: number;
height: number;
person_id: string;
}>;
}
/**
* 视频片段服务Hook
* 提供视频片段相关的所有状态管理和操作方法
* 包括获取视频列表、重新生成视频、AI优化等功能
*/
export const useShotService = (): UseShotService => {
// 响应式状态
const [loading, setLoading] = useState<boolean>(false);
const [videoSegments, setVideoSegments] = useState<VideoSegmentEntity[]>([]);
const [scriptRoles, setScriptRoles] = useState<ScriptRoleEntity[]>([]);
const [selectedSegment, setSelectedSegment] =
useState<VideoSegmentEntity | null>(null);
const [projectId, setProjectId] = useState<string>("");
const [simpleCharacter, setSimpleCharacter] = useState<SimpleCharacter[]>([]);
const [matched_persons, setMatched_persons] = useState<MatchedPerson[]>([]);
// 轮询任务ID
const [intervalId, setIntervalId] = useState<NodeJS.Timeout | null>(null);
// UseCase实例
const [vidoEditUseCase] = useState<VideoSegmentEditUseCase>(
new VideoSegmentEditUseCase()
);
const [generateTaskIds, setGenerateTaskIds] = useState<Set<string>>(new Set());
/**
* 获取视频片段列表
* @param projectId 项目ID
*/
const getVideoSegmentList = useCallback(
async (projectId: string): Promise<void> => {
try {
setLoading(true);
const { segments, roles } = await vidoEditUseCase.getVideoSegmentList(projectId);
setProjectId(projectId);
setVideoSegments(segments);
setScriptRoles(roles);
} catch (error) {
console.error("获取视频片段列表失败:", error);
} finally {
setLoading(false);
}
},
[vidoEditUseCase]
);
// 组件卸载时清理定时器
useEffect(() => {
return () => {
if (intervalId) {
clearInterval(intervalId);
setIntervalId(null);
}
};
}, [intervalId]);
/**
* 轮询获取视频生成状态
* @param taskId - 任务ID
* @returns Promise<void>
*/
// 使用 ref 来跟踪组件是否已卸载
const isComponentMounted = useRef(true);
// 在组件挂载时设置为 true卸载时设置为 false
useEffect(() => {
isComponentMounted.current = true;
return () => {
isComponentMounted.current = false;
};
}, []);
const pollVideoStatus = useCallback(async (taskId: string): Promise<void> => {
const maxAttempts = 60; // 最大轮询次数
const interval = 10000; // 轮询间隔时间(毫秒)
let attempts = 0;
let timeoutId: NodeJS.Timeout;
const poll = async (): Promise<void> => {
// 如果组件已卸载,停止轮询
if (!isComponentMounted.current) {
if (timeoutId) {
clearTimeout(timeoutId);
}
return;
}
try {
const result = await getNewShotVideo({ task_id: taskId });
if (!result?.data) {
// 更新 selectedSegment
setSelectedSegment(prev => ({
...prev!,
videoUrl: [],
video_status: 2 // 设置为失败状态
}));
// 同步更新 videoSegments 中的对应项
setVideoSegments(prev =>
prev.map(segment =>
segment.id === selectedSegment!.id
? {
...segment,
videoUrl: [],
video_status: 2,
status: 2 // 更新片段状态为失败
}
: segment
)
);
}
const { status, urls, message } = result.data;
// 如果任务完成或失败,更新视频片段数据
if (status === 'COMPLETED' || status === 'FAILED') {
if (selectedSegment && status === 'COMPLETED' && urls?.length > 0) {
// 更新 selectedSegment
const updatedVideos = urls.map((url: string) => ({
video_url: url,
video_id: selectedSegment!.id,
video_status: 1
}));
setSelectedSegment(prev => ({
...prev!,
videoUrl: updatedVideos,
video_status: 1 // 设置为已完成状态
}));
// 同步更新 videoSegments 中的对应项
setVideoSegments(prev =>
prev.map(segment =>
segment.id === selectedSegment.id
? {
...segment,
videoUrl: updatedVideos,
video_status: 1,
status: 1 // 更新片段状态为完成
}
: segment
)
);
} else {
// 更新 selectedSegment
setSelectedSegment(prev => ({
...prev!,
videoUrl: [],
video_status: 2 // 设置为失败状态
}));
// 同步更新 videoSegments 中的对应项
setVideoSegments(prev =>
prev.map(segment =>
segment.id === selectedSegment!.id
? {
...segment,
videoUrl: [],
video_status: 2,
status: 2 // 更新片段状态为失败
}
: segment
)
);
}
return;
}
// 如果未完成且未达到最大尝试次数,继续轮询
if (attempts < maxAttempts && isComponentMounted.current) {
attempts++;
timeoutId = setTimeout(poll, interval);
} else {
setSelectedSegment(prev => ({
...prev!,
video_status: 2 // 设置为失败状态
}));
}
} catch (error) {
setSelectedSegment(prev => ({
...prev!,
video_status: 2 // 设置为失败状态
}));
}
};
await poll();
}, [selectedSegment]);
/**
* 重新生成视频片段
* @param shotPrompt 镜头描述数据
* @param shotId 视频片段ID可选
* @param roleReplaceParams 角色替换参数(可选)
* @param sceneReplaceParams 场景替换参数(可选)
* @returns Promise<VideoSegmentEntity> 重新生成的视频片段
*/
const regenerateVideoSegment =
useCallback(async (): Promise<VideoSegmentEntity> => {
try {
setLoading(true);
// 调用API重新生成视频片段返回任务状态信息
const taskResult = await vidoEditUseCase.regenerateVideoSegment(
projectId,
selectedSegment!.lens,
selectedSegment!.id
);
// 保存任务ID用于后续状态查询
setGenerateTaskIds(prev => prev.add(taskResult.task_id));
SaveEditUseCase.setVideoTasks([
...SaveEditUseCase.videoTasks,
{
task_id: taskResult.task_id,
video_ids: [selectedSegment!.id],
},
]);
// 如果重新生成的是现有片段,更新其状态为处理中 (0: 视频加载中)
if (selectedSegment) {
setVideoSegments((prev) =>
prev.map((segment) =>
segment.id === selectedSegment.id
? {
...segment,
videoUrl: [],
video_status: 0,
status: 0, // 设置为视频加载中状态
}
: segment
)
);
// 开始轮询视频生成状态
await pollVideoStatus(taskResult.task_id);
}
return selectedSegment!;
} catch (error) {
console.error("重新生成视频片段失败:", error);
throw error;
} finally {
setLoading(false);
}
}, [projectId, selectedSegment, vidoEditUseCase, pollVideoStatus]);
/**
* AI优化视频内容
* @param shotId 视频片段ID
* @param userRequirement 用户优化需求
* @param lensData 镜头数据数组
* @returns Promise<LensType[]> 优化后的镜头数据
*/
const optimizeVideoContent = useCallback(
async (
shotId: string,
userRequirement: string,
lensData: LensType[]
): Promise<LensType[]> => {
try {
setLoading(true);
const optimizedLensData = await vidoEditUseCase.optimizeVideoContent(
shotId,
userRequirement,
lensData
);
// 注意这里不再更新videoSegments状态因为返回的是LensType[]而不是VideoSegmentEntity
// 调用者需要自己处理优化后的镜头数据
return optimizedLensData;
} catch (error) {
console.error("AI优化视频内容失败:", error);
throw error;
} finally {
setLoading(false);
}
},
[vidoEditUseCase]
);
/**
* 中断当前操作
*/
const abortOperation = useCallback((): void => {
// vidoEditUseCase.abortOperation();
setLoading(false);
}, []);
/**
* 设置选中的视频片段
*/
const setSelectedSegmentHandler = useCallback(
(segment: VideoSegmentEntity | null): void => {
setSelectedSegment(segment);
},
[]
);
/**
* 添加新镜头到选中的视频片段
* @description 在selectedSegment的lens数组中添加一个新的空镜头镜头名称按顺序命名
*/
const addNewLens = useCallback((): void => {
if (!selectedSegment) {
console.warn("没有选中的视频片段,无法添加镜头");
return;
}
// 计算下一个镜头编号
const currentLensCount = selectedSegment.lens.length;
const newLensName = `镜头${currentLensCount + 1}`;
// 创建新的空镜头
const newLens = new LensType(newLensName, "", []);
// 创建更新后的片段
const updatedSegment: VideoSegmentEntity = {
...selectedSegment,
lens: [...selectedSegment.lens, newLens],
};
// 批量更新状态,避免多次重渲染
setSelectedSegment(updatedSegment);
setVideoSegments((prev) => {
const segmentIndex = prev.findIndex(
(segment) => segment.id === selectedSegment.id
);
if (segmentIndex === -1) return prev;
const newSegments = [...prev];
newSegments[segmentIndex] = updatedSegment;
return newSegments;
});
}, [selectedSegment]);
/**
* 删除指定镜头
* @param lensName 要删除的镜头名称
*/
const deleteLens = useCallback(
(lensName: string): void => {
if (!selectedSegment) {
console.warn("没有选中的视频片段,无法删除镜头");
return;
}
// 过滤掉指定名称的镜头并重新排序
const updatedLens = selectedSegment.lens
.filter((lens) => lens.name !== lensName)
.map(
(lens, index) =>
new LensType(`镜头${index + 1}`, lens.script, lens.content)
);
// 创建更新后的片段
const updatedSegment: VideoSegmentEntity = {
...selectedSegment,
lens: updatedLens,
};
// 批量更新状态,避免多次重渲染
setSelectedSegment(updatedSegment);
setVideoSegments((prev) => {
const segmentIndex = prev.findIndex(
(segment) => segment.id === selectedSegment.id
);
if (segmentIndex === -1) return prev;
const newSegments = [...prev];
newSegments[segmentIndex] = updatedSegment;
return newSegments;
});
},
[selectedSegment]
);
/**
* 获取视频当前帧的画面,上传到七牛云,并返回七牛云的图片地址,然后调用接口识别出里面的人物信息,返回人物信息
* @param video HTML视频元素
* @param projectId 项目ID
* @param videoId 视频ID
* @returns Promise<string> 七牛云的图片地址
*/
const filterRole = useCallback(
async (video: HTMLVideoElement) => {
try {
// 创建canvas元素来截取视频帧
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
console.log(video);
video.crossOrigin = "anonymous";
if (!ctx) {
throw new Error("无法获取canvas上下文");
}
// 设置canvas尺寸为视频尺寸
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
// 将当前视频帧绘制到canvas上
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
// 将canvas转换为blob
const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) {
resolve(blob);
} else {
reject(new Error("无法将canvas转换为blob"));
}
}, "image/png");
});
// 创建File对象
const file = new File([blob], `frame_${Date.now()}.png`, {
type: "image/png",
});
// 获取上传token
const { token } = await getUploadToken();
// 上传到七牛云
const imageUrl = await uploadToQiniu(file, token);
// 调用用例中的识别角色方法
try {
const recognitionResult =
await vidoEditUseCase.recognizeRoleFromImage(
projectId,
selectedSegment!.id,
imageUrl
);
console.log("角色识别结果:", recognitionResult);
setMatched_persons(
recognitionResult.recognition_result.data
);
return recognitionResult;
} catch (recognitionError) {
console.warn("角色识别失败,但图片上传成功:", recognitionError);
}
} catch (error) {
console.error("获取视频帧失败:", error);
throw error;
}
},
[projectId, selectedSegment, vidoEditUseCase]
);
/**
* 计算识别框的属性
* @description 根据DOM元素尺寸和匹配数据计算识别框的位置和尺寸
* @param containerElement DOM容器元素
* @returns 计算后的识别框属性数组
*/
const calculateRecognitionBoxes = (
containerElement: HTMLElement,
matched_persons: MatchedPerson[] = []
): Array<{
/** 横向定位坐标 */
left: number;
/** 纵向定位坐标 */
top: number;
/** 宽度 */
width: number;
/** 高度 */
height: number;
/** 人物ID */
person_id: string;
}> => {
// 获取容器元素的尺寸
const containerRect = containerElement.getBoundingClientRect();
const containerWidth = containerRect.width;
const containerHeight = containerRect.height;
// 计算识别框属性
return matched_persons
.map((person) => {
// 计算绝对坐标和尺寸(百分比转像素)
const left = Number((person.x || 0).toFixed(4)) * containerWidth;
const top = Number((person.y || 0).toFixed(4)) * containerHeight;
const width = Number((person.width || 0).toFixed(4)) * containerWidth;
const height = Number((person.height || 0).toFixed(4)) * containerHeight;
console.log('left-top-width-height', left, top, width, height);
return {
left,
top,
width,
height,
person_id: person.person_id
};
})
.filter(Boolean) as Array<{
left: number;
top: number;
width: number;
height: number;
person_id: string;
}>;
};
return {
// 响应式状态
loading,
videoSegments,
scriptRoles,
selectedSegment,
matched_persons,
// 操作方法
getVideoSegmentList,
regenerateVideoSegment,
optimizeVideoContent,
abortOperation,
setSelectedSegment: setSelectedSegmentHandler,
addNewLens,
deleteLens,
filterRole,
setSimpleCharacter,
calculateRecognitionBoxes
};
};