forked from 77media/video-flow
335 lines
9.6 KiB
TypeScript
335 lines
9.6 KiB
TypeScript
/**
|
||
* AI剪辑适配器 - 将OpenCut的AI剪辑逻辑适配到Video-Flow
|
||
* 文件路径: video-flow/components/pages/work-flow/ai-editing-adapter.ts
|
||
* 作者: 资深全栈开发工程师
|
||
* 创建时间: 2025-01-08
|
||
*/
|
||
|
||
// 从OpenCut项目导入的类型定义
|
||
interface AIEditingPlan {
|
||
version_name: string;
|
||
version_summary: string;
|
||
timeline_clips: AIClipData[];
|
||
}
|
||
|
||
interface AIClipData {
|
||
sequence_clip_id: string;
|
||
source_clip_id: string;
|
||
video_url: string;
|
||
source_in_timecode: string;
|
||
source_out_timecode: string;
|
||
sequence_start_timecode: string;
|
||
clip_duration_in_sequence: string;
|
||
corresponding_script_scene_id?: string;
|
||
}
|
||
|
||
interface AIEditingData {
|
||
editing_plan: {
|
||
editing_sequence_plans: AIEditingPlan[];
|
||
};
|
||
subtitle_data?: {
|
||
final_dialogue_segments?: any[];
|
||
final_srt_content?: string;
|
||
};
|
||
}
|
||
|
||
interface ExportOptions {
|
||
quality: 'low' | 'standard' | 'high';
|
||
format: 'mp4' | 'webm' | 'mov';
|
||
fps?: number;
|
||
}
|
||
|
||
/**
|
||
* Video-Flow项目的任务对象类型
|
||
*/
|
||
interface VideoFlowTaskObject {
|
||
title: string;
|
||
currentStage: string;
|
||
videos: {
|
||
data: Array<{
|
||
urls?: string[];
|
||
video_id: string;
|
||
video_status: number;
|
||
}>;
|
||
total_count: number;
|
||
};
|
||
final: {
|
||
url: string;
|
||
note: string;
|
||
};
|
||
}
|
||
|
||
/**
|
||
* AI剪辑适配器类
|
||
* 负责将Video-Flow的视频数据转换为OpenCut的AI剪辑格式,并执行自动化剪辑流程
|
||
*/
|
||
export class AIEditingAdapter {
|
||
private projectId: string;
|
||
private taskObject: VideoFlowTaskObject;
|
||
private onProgress?: (progress: number, message: string) => void;
|
||
private onComplete?: (finalVideoUrl: string) => void;
|
||
private onError?: (error: string) => void;
|
||
|
||
constructor(
|
||
projectId: string,
|
||
taskObject: VideoFlowTaskObject,
|
||
callbacks?: {
|
||
onProgress?: (progress: number, message: string) => void;
|
||
onComplete?: (finalVideoUrl: string) => void;
|
||
onError?: (error: string) => void;
|
||
}
|
||
) {
|
||
this.projectId = projectId;
|
||
this.taskObject = taskObject;
|
||
this.onProgress = callbacks?.onProgress;
|
||
this.onComplete = callbacks?.onComplete;
|
||
this.onError = callbacks?.onError;
|
||
}
|
||
|
||
/**
|
||
* 将Video-Flow的视频数据转换为AI剪辑计划格式
|
||
*/
|
||
private convertToAIEditingPlan(): AIEditingPlan {
|
||
const clips: AIClipData[] = [];
|
||
|
||
// 遍历Video-Flow的视频数据,转换为AI剪辑格式
|
||
this.taskObject.videos.data.forEach((video, index) => {
|
||
if (video.video_status === 1 && video.urls && video.urls.length > 0) {
|
||
// 只处理生成成功的视频
|
||
const clip: AIClipData = {
|
||
sequence_clip_id: `clip_${index + 1}`,
|
||
source_clip_id: video.video_id,
|
||
video_url: video.urls[0], // 取第一个URL
|
||
source_in_timecode: "00:00:00:00",
|
||
source_out_timecode: this.estimateClipDuration(index),
|
||
sequence_start_timecode: this.calculateSequenceStart(index),
|
||
clip_duration_in_sequence: this.estimateClipDuration(index),
|
||
corresponding_script_scene_id: `scene_${index + 1}`
|
||
};
|
||
clips.push(clip);
|
||
}
|
||
});
|
||
|
||
return {
|
||
version_name: `${this.taskObject.title} - AI剪辑版本`,
|
||
version_summary: `基于${clips.length}个视频片段的智能剪辑,自动优化节奏和转场效果`,
|
||
timeline_clips: clips
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 估算视频片段时长(默认5秒,实际项目中应该获取真实时长)
|
||
*/
|
||
private estimateClipDuration(index: number): string {
|
||
const duration = 5; // 默认5秒
|
||
const frames = Math.floor(duration * 30); // 30fps
|
||
return `00:00:0${duration}:${frames.toString().padStart(2, '0')}`;
|
||
}
|
||
|
||
/**
|
||
* 计算序列开始时间码
|
||
*/
|
||
private calculateSequenceStart(index: number): string {
|
||
const startSeconds = index * 5; // 每个片段5秒
|
||
const minutes = Math.floor(startSeconds / 60);
|
||
const seconds = startSeconds % 60;
|
||
return `00:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}:00`;
|
||
}
|
||
|
||
/**
|
||
* 执行自动化AI剪辑流程
|
||
* 这是核心方法,整合了OpenCut的一键剪辑逻辑
|
||
*/
|
||
public async executeAutoAIEditing(): Promise<string> {
|
||
try {
|
||
this.onProgress?.(10, "初始化AI剪辑系统...");
|
||
|
||
// 第一步:生成AI剪辑计划
|
||
const aiEditingPlan = this.convertToAIEditingPlan();
|
||
|
||
if (aiEditingPlan.timeline_clips.length === 0) {
|
||
throw new Error("没有可用的视频片段进行剪辑");
|
||
}
|
||
|
||
this.onProgress?.(25, `已识别${aiEditingPlan.timeline_clips.length}个视频片段`);
|
||
|
||
// 第二步:构建导出请求数据
|
||
const exportRequest = {
|
||
clips: aiEditingPlan.timeline_clips,
|
||
subtitles: "", // 暂时不处理字幕
|
||
totalDuration: this.calculateTotalDuration(aiEditingPlan.timeline_clips),
|
||
options: {
|
||
quality: 'standard' as const,
|
||
format: 'mp4' as const,
|
||
fps: 30
|
||
}
|
||
};
|
||
|
||
this.onProgress?.(40, "开始智能剪辑处理...");
|
||
|
||
// 第三步:调用OpenCut的导出API
|
||
const finalVideoUrl = await this.callExportAPI(exportRequest);
|
||
|
||
this.onProgress?.(90, "剪辑完成,正在生成最终视频...");
|
||
|
||
// 第四步:更新Video-Flow项目状态
|
||
await this.updateVideoFlowProject(finalVideoUrl);
|
||
|
||
this.onProgress?.(100, "AI剪辑流程完成!");
|
||
this.onComplete?.(finalVideoUrl);
|
||
|
||
return finalVideoUrl;
|
||
|
||
} catch (error) {
|
||
const errorMessage = error instanceof Error ? error.message : "AI剪辑过程中发生未知错误";
|
||
console.error("AI剪辑失败:", error);
|
||
this.onError?.(errorMessage);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 计算总时长
|
||
*/
|
||
private calculateTotalDuration(clips: AIClipData[]): number {
|
||
// 简单估算:每个片段5秒
|
||
return clips.length * 5;
|
||
}
|
||
|
||
/**
|
||
* 调用Video-Flow的导出API
|
||
* 复用OpenCut项目中的 /api/export/ai-clips 逻辑
|
||
*/
|
||
private async callExportAPI(exportRequest: any): Promise<string> {
|
||
const response = await fetch('/api/export/ai-clips', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify(exportRequest)
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`导出API调用失败: ${response.statusText}`);
|
||
}
|
||
|
||
// 处理流式响应
|
||
const reader = response.body?.getReader();
|
||
if (!reader) {
|
||
throw new Error("无法读取响应流");
|
||
}
|
||
|
||
let finalVideoUrl = "";
|
||
const decoder = new TextDecoder();
|
||
|
||
try {
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
|
||
const chunk = decoder.decode(value);
|
||
const lines = chunk.split('\n');
|
||
|
||
for (const line of lines) {
|
||
if (line.startsWith('data: ')) {
|
||
try {
|
||
const data = JSON.parse(line.slice(6));
|
||
|
||
// 更新进度
|
||
if (data.type === 'progress') {
|
||
const progress = 40 + (data.progress * 40); // 40-80% 区间
|
||
this.onProgress?.(progress, data.message || "处理中...");
|
||
}
|
||
|
||
// 获取最终结果
|
||
if (data.type === 'completed' && data.downloadUrl) {
|
||
finalVideoUrl = data.downloadUrl;
|
||
}
|
||
} catch (e) {
|
||
console.warn("解析响应数据失败:", e);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} finally {
|
||
reader.releaseLock();
|
||
}
|
||
|
||
if (!finalVideoUrl) {
|
||
throw new Error("未能获取最终视频URL");
|
||
}
|
||
|
||
return finalVideoUrl;
|
||
}
|
||
|
||
/**
|
||
* 更新Video-Flow项目状态
|
||
* 将AI剪辑结果更新到Video-Flow的数据结构中
|
||
*/
|
||
private async updateVideoFlowProject(finalVideoUrl: string): Promise<void> {
|
||
try {
|
||
// 构建任务结果数据,模拟Video-Flow的粗剪接口调用
|
||
const taskResult = {
|
||
task_result: JSON.stringify({
|
||
video: finalVideoUrl
|
||
}),
|
||
task_name: "generate_final_simple_video",
|
||
project_id: this.projectId
|
||
};
|
||
|
||
console.log('🎬 更新Video-Flow项目状态:');
|
||
console.log(' - 项目ID:', this.projectId);
|
||
console.log(' - 最终视频URL:', finalVideoUrl);
|
||
console.log(' - 任务结果:', taskResult);
|
||
|
||
// 这里可以调用Video-Flow的API来更新项目状态
|
||
// 或者直接更新本地状态(取决于具体需求)
|
||
|
||
} catch (error) {
|
||
console.warn("更新Video-Flow项目状态失败:", error);
|
||
// 不抛出错误,避免影响主流程
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 静态方法:检查是否可以执行AI剪辑
|
||
*/
|
||
public static canExecuteAIEditing(taskObject: VideoFlowTaskObject): boolean {
|
||
// 检查是否有可用的视频片段
|
||
const availableVideos = taskObject.videos.data.filter(
|
||
video => video.video_status === 1 && video.urls && video.urls.length > 0
|
||
);
|
||
|
||
return availableVideos.length > 0;
|
||
}
|
||
|
||
/**
|
||
* 静态方法:获取可用视频数量
|
||
*/
|
||
public static getAvailableVideoCount(taskObject: VideoFlowTaskObject): number {
|
||
return taskObject.videos.data.filter(
|
||
video => video.video_status === 1 && video.urls && video.urls.length > 0
|
||
).length;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 导出工厂函数,方便在组件中使用
|
||
*/
|
||
export function createAIEditingAdapter(
|
||
projectId: string,
|
||
taskObject: VideoFlowTaskObject,
|
||
callbacks?: {
|
||
onProgress?: (progress: number, message: string) => void;
|
||
onComplete?: (finalVideoUrl: string) => void;
|
||
onError?: (error: string) => void;
|
||
}
|
||
): AIEditingAdapter {
|
||
return new AIEditingAdapter(projectId, taskObject, callbacks);
|
||
}
|
||
|
||
/**
|
||
* 导出类型定义供其他文件使用
|
||
*/
|
||
export type { VideoFlowTaskObject, AIEditingPlan, AIClipData, ExportOptions };
|