From 08f5c83396aec84f8cfda46461bfb40a6d3d8545 Mon Sep 17 00:00:00 2001 From: qikongjian Date: Tue, 16 Sep 2025 22:09:34 +0800 Subject: [PATCH] =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=89=AA=E8=BE=91=E8=B0=83?= =?UTF-8?q?=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/3.md | 119 +++++ docs/API_EXPORT_STREAM_GUIDE.md | 322 ++++++++++++++ docs/剪辑计划.md | 114 +++++ docs/请求.md | 5 + utils/export-service.ts | 740 ++++++++++++++++++++++++++++++++ 5 files changed, 1300 insertions(+) create mode 100644 docs/3.md create mode 100644 docs/API_EXPORT_STREAM_GUIDE.md create mode 100644 docs/剪辑计划.md create mode 100644 docs/请求.md create mode 100644 utils/export-service.ts diff --git a/docs/3.md b/docs/3.md new file mode 100644 index 0000000..baf6e46 --- /dev/null +++ b/docs/3.md @@ -0,0 +1,119 @@ +https://smartcut.api.movieflow.ai/api/export/stream + +post 请求 + +{"ir":{"width":1920,"height":1080,"fps":30,"duration":88000,"video":[{"id":"ec87abdf-dcaa-4460-ad82-f453b6a47519","src":"https://video-base-imf.oss-ap-southeast-7.aliyuncs.com/uploads/FJ1_47e2b503-c6e7-457e-b179-17ed2533ece4-20250912151950.mp4","in":0,"out":8000,"start":0,"trackId":"d5b8a916-8e13-4955-a677-c7f12fd6037c","transform":{"x":0,"y":0,"scale":1,"rotate":0,"horizontalFlip":false,"verticalFlip":false},"muted":false,"actualPath":"https://video-base-imf.oss-ap-southeast-7.aliyuncs.com/uploads/FJ1_47e2b503-c6e7-457e-b179-17ed2533ece4-20250912151950.mp4","metadata":{"size":0,"duration":8,"width":1920,"height":1080,"type":"video"}},{"id":"6c029f2d-5d99-403a-81be-fc4629c51873","src":"https://video-base-imf.oss-ap-southeast-7.aliyuncs.com/uploads/FJ2_7d99f823-fb39-46cf-bdc9-0de8b97762a5-20250912151854.mp4","in":0,"out":8000,"start":8000,"trackId":"d5b8a916-8e13-4955-a677-c7f12fd6037c","transform":{"x":0,"y":0,"scale":1,"rotate":0,"horizontalFlip":false,"verticalFlip":false},"muted":false,"actualPath":"https://video-base-imf.oss-ap-southeast-7.aliyuncs.com/uploads/FJ2_7d99f823-fb39-46cf-bdc9-0de8b97762a5-20250912151854.mp4","metadata":{"size":0,"duration":8,"width":1920,"height":1080,"type":"video"}},{"id":"d0d2a8f8-f4b9-4dfe-89de-76947adc6d49","src":"https://video-base-imf.oss-ap-southeast-7.aliyuncs.com/uploads/FJ3_e0d53da3-e48f-4b8b-b559-c280d0fc2c3f-20250912151949.mp4","in":0,"out":8000,"start":16000,"trackId":"d5b8a916-8e13-4955-a677-c7f12fd6037c","transform":{"x":0,"y":0,"scale":1,"rotate":0,"horizontalFlip":false,"verticalFlip":false},"muted":false,"actualPath":"https://video-base-imf.oss-ap-southeast-7.aliyuncs.com/uploads/FJ3_e0d53da3-e48f-4b8b-b559-c280d0fc2c3f-20250912151949.mp4","metadata":{"size":0,"duration":8,"width":1920,"height":1080,"type":"video"}},{"id":"006ba900-5f1b-4f4b-86c2-4231f717dd4d","src":"https://video-base-imf.oss-ap-southeast-7.aliyuncs.com/uploads/FJ4_580198cb-99fa-485f-891f-3cd15b1d4f51-20250912151950.mp4","in":0,"out":8000,"start":24000,"trackId":"d5b8a916-8e13-4955-a677-c7f12fd6037c","transform":{"x":0,"y":0,"scale":1,"rotate":0,"horizontalFlip":false,"verticalFlip":false},"muted":false,"actualPath":"https://video-base-imf.oss-ap-southeast-7.aliyuncs.com/uploads/FJ4_580198cb-99fa-485f-891f-3cd15b1d4f51-20250912151950.mp4","metadata":{"size":0,"duration":8,"width":1920,"height":1080,"type":"video"}},{"id":"a1a41f72-86d1-47b6-8e1d-26a479a3fb95","src":"https://video-base-imf.oss-ap-southeast-7.aliyuncs.com/uploads/FJ5_d9420926-c9de-44be-937d-fc34b99bbc6b-20250912151853.mp4","in":0,"out":8000,"start":32000,"trackId":"d5b8a916-8e13-4955-a677-c7f12fd6037c","transform":{"x":0,"y":0,"scale":1,"rotate":0,"horizontalFlip":false,"verticalFlip":false},"muted":false,"actualPath":"https://video-base-imf.oss-ap-southeast-7.aliyuncs.com/uploads/FJ5_d9420926-c9de-44be-937d-fc34b99bbc6b-20250912151853.mp4","metadata":{"size":0,"duration":8,"width":1920,"height":1080,"type":"video"}},{"id":"62eee9f0-1040-4afc-a22b-c9c3ceddb360","Show more + +剪辑计划完整数据 +{ +"project_id": "3a994c3f-8a98-45e5-be9b-cdf3fabcc335", +"director_intent": "", +"success": true, +"editing_plan": { +"finalized_dialogue_track": { +"final_dialogue_segments": [ +{ +"sequence_clip_id": "seq_clip_001", +"source_clip_id": "E1-S1-C01", +"start_timecode": "00:00:00.000", +"end_timecode": "00:00:08.000", +"transcript": "Discipline is a fortress built stone by stone.", +"speaker": "JIN (V.O.)" +}, +{ +"sequence_clip_id": "seq_clip_003", +"source_clip_id": "E1-S1-C05", +"start_timecode": "00:00:00.000", +"end_timecode": "00:00:02.000", +"transcript": "But every fortress has a gate.", +"speaker": "JIN (V.O.)" +}, +{ +"sequence_clip_id": "seq_clip_005", +"source_clip_id": "E1-S1-C06_C07_Combined", +"start_timecode": "00:00:02.000", +"end_timecode": "00:00:04.000", +"transcript": "Li. To the inner chambers. Go.", +"speaker": "JIN" +}, +{ +"sequence_clip_id": "seq_clip_009", +"source_clip_id": "E1-S1-C19_E1-S1-C20", +"start_timecode": "00:00:01.000", +"end_timecode": "00:00:03.000", +"transcript": "And some lessons are taught in blood.", +"speaker": "JIN (V.O.)" +} +] +}, +"material_classification_results": { +"discarded_footage_list": [ +{ +"clip_id": "E1-S1-C15-16_Combined", +"video_url": "https://video-base-imf.oss-ap-southeast-7.aliyuncs.com/uploads/FJ10_5d3d2102-a1a0-44f2-afb1-10ce66690c00-20250916160659.mp4", +"reason": "该素材包含严重 AI 生成伪影和恐怖谷效应,导致核心内容无法识别,且存在更好的替代品(E1-S1-C13_E1-S1-C14_Sequence 的结尾部分可以替代其叙事功能)。" +}, +{ +"clip_id": "E1-S1-C17_E1-S1-C18_Combined", +"video_url": "https://video-base-imf.oss-ap-southeast-7.aliyuncs.com/uploads/FJ11_7db3d22f-f7c1-40bb-9b14-cb8b4993adc3-20250916160659.mp4", +"reason": "该素材所有片段均存在严重 AI 生成缺陷,无法有效传达核心价值,且无法通过后期修复。已在 production_suggestions 中请求补拍。" +} +], +"alternative_footage_list": [] +}, +"editing_sequence_plans": [ +{ +"version_name": "Final Cut - Action Focus", +"version_summary": "本剪辑方案严格遵循默奇六原则和最高指令,以快节奏、高信息密度和紧张感为核心,构建了一个从宁静到暴力冲突的叙事弧线。优先保留情感和故事驱动的镜头,对物理连贯性 Bug 采取容忍或修复策略,确保故事连贯性。", +"timeline_clips": [ +{ +"sequence_clip_id": "seq_clip_001", +"source_clip_id": "E1-S1-C01", +"video_url": "https://video-base-imf.oss-ap-southeast-7.aliyuncs.com/uploads/FJ1_9a868131-8594-43f1-84a6-f83b2246b91f-20250916160702.mp4", +"corresponding_script_scene_id": "SCENE 1", +"clip_type": "Establishing Shot", +"sequence_start_timecode": "00:00:00.000", +"source_in_timecode": "00:00:00.000", +"source_out_timecode": "00:00:08.000", +"clip_duration_in_sequence": "00:00:08.000", +"transition_from_previous": { +"transition_type": "Fade In", +"transition_duration_ms": 1000, +"audio_sync_offset_ms": 0, +"reason_for_transition": "从黑场淡入,强调宁静的开场,与金大师的画外音完美结合,建立氛围。" +}, +"clip_placement_reasons": { +"prime_directive_justification": "此为场景开场,建立环境和角色,是叙事不可或缺的节拍。", +"core_intent_and_audience_effect": "通过广阔的夜景和金大师的画外音,建立修道院的宁静与纪律,为后续的暴力打破提供强烈对比,引发观众对和平被侵犯的共鸣。", +"emotion_priority_51": "传达宁静、秩序与潜在的脆弱感。", +"story_priority_23": "引入主要角色(李和金),建立场景背景,并以画外音点明主题。", +"rhythm_priority_10": "缓慢的开场节奏,为后续的冲突积蓄力量。", +"eyeline_priority_7": "高角度俯瞰,引导观众总览整个场景。", +"2d_space_priority_5": "静态镜头,无轴线问题。", +"3d_space_priority_4": "空间布局清晰,道具位置合理。", +"lens_language_application": "高角度 EWS,强调环境的广阔和人物的渺小,营造史诗感。" +}, +"continuity_correction_details": { +"error_detected_in_audit": true, +"error_type": "Script-to-Picture Mismatch (Camera Movement)", +"error_description": "剧本要求慢速摇臂和摇摄,但素材为静态广角镜头。李的扫地动作重复机械。", +"resolution_strategy_applied": "Tolerate (Sole Coverage) & VFX Suggested", +"details": "尽管运镜不符,但该镜头作为开场建立场景的功能强大,且是唯一覆盖素材。选择容忍其静态运镜,并建议后期 VFX 修复李的扫地动作以增加自然度。其核心叙事价值(建立宁静)高于运镜缺陷。" +}, +"sound_design_suggestions": [ +{ +"sound_type": "Ambient Sound", +"description": "加入微弱的夜间环境音,如风声、远处虫鸣,以增强宁静感。", +"timing_in_clip": "00:00:00.000 - 00:00:08.000", +"intensity_suggestion": "Low" +}, +{ +"sound_type": "Voice-over", +"description": "确保画外音清晰、洪亮,与画面氛围匹配。", +"timing_in_clip": "00:00:00.000 - 00:00:08.000", +"intensity_suggestion": "Medium" +} +], +"visual_enhancement_suggestions": [ +{ +"enh diff --git a/docs/API_EXPORT_STREAM_GUIDE.md b/docs/API_EXPORT_STREAM_GUIDE.md new file mode 100644 index 0000000..62c1c8f --- /dev/null +++ b/docs/API_EXPORT_STREAM_GUIDE.md @@ -0,0 +1,322 @@ +# 视频导出流式接口 API 规范 + +## 接口概述 + +**接口地址**: `POST /api/export/stream` +**接口类型**: Server-Sent Events (SSE) 流式接口 +**功能描述**: 实时流式视频导出,支持进度推送和高质量流复制模式 + +## 请求参数 + +### 请求头 +```http +Content-Type: application/json +Accept: text/event-stream +``` + +### 请求体结构 + +```typescript +interface ExportRequest { + project_id?: string; // 项目ID(可选) + ir: IRData; // 时间轴中间表示数据(必需) + options?: ExportOptions; // 导出选项(可选) + videoFiles?: Record; // 视频文件base64数据(可选) +} +``` + +## 详细参数说明 + +### 1. project_id (可选) +- **类型**: `string` +- **描述**: 项目唯一标识符 +- **默认值**: 如果未提供,系统会生成 `default_project_{task_id前8位}` +- **示例**: `"project_12345"` + +### 2. ir (必需) - 时间轴中间表示数据 + +```typescript +interface IRData { + width: number; // 视频宽度(必需) + height: number; // 视频高度(必需) + fps: number; // 帧率(必需) + duration: number; // 总时长,单位毫秒(必需) + video: VideoElement[]; // 视频轨道数据(必需) + texts?: TextElement[]; // 字幕轨道数据(可选) + audio?: AudioElement[]; // 音频轨道数据(可选) + transitions?: TransitionElement[]; // 转场效果(可选) +} +``` + +#### VideoElement 结构 +```typescript +interface VideoElement { + id: string; // 视频元素唯一ID + src: string; // 视频源路径/URL/blob URL + start: number; // 在时间轴上的开始时间(毫秒) + end?: number; // 在时间轴上的结束时间(毫秒) + in: number; // 视频内部开始时间(毫秒) + out: number; // 视频内部结束时间(毫秒) + _source_type?: 'local' | 'remote_url' | 'blob'; // 源类型标识 +} +``` + +#### TextElement 结构 +```typescript +interface TextElement { + id: string; // 字幕元素唯一ID + text: string; // 字幕内容 + start: number; // 开始时间(毫秒) + end: number; // 结束时间(毫秒) + style?: TextStyle; // 字幕样式 +} + +interface TextStyle { + fontFamily?: string; // 字体,默认 'Arial' + fontSize?: number; // 字体大小,默认 40 + color?: string; // 字体颜色,默认 '#FFFFFF' + backgroundColor?: string; // 背景色,默认 'transparent' + fontWeight?: 'normal' | 'bold'; // 字体粗细 + fontStyle?: 'normal' | 'italic'; // 字体样式 + align?: 'left' | 'center' | 'right'; // 对齐方式 + shadow?: boolean; // 是否显示阴影 + rotation?: number; // 旋转角度 +} +``` + +### 3. options (可选) - 导出选项 + +```typescript +interface ExportOptions { + quality?: 'preview' | 'standard' | 'professional'; // 质量等级 + codec?: string; // 编码器,默认 'libx264' + subtitleMode?: 'hard' | 'soft'; // 字幕模式,默认 'hard' + bitrate?: string; // 比特率,如 '5000k' + preset?: string; // 编码预设,如 'medium' +} +``` + +**默认值**: +```json +{ + "quality": "standard", + "codec": "libx264", + "subtitleMode": "hard" +} +``` + +### 4. videoFiles (可选) - Base64视频数据 + +```typescript +interface VideoFiles { + [blobId: string]: string; // blobId -> base64编码的视频数据 +} +``` + +**使用场景**: 当 `VideoElement.src` 为 blob URL 时,需要提供对应的 base64 数据 + +## 完整请求示例 + +### 基础示例 +```json +{ + "project_id": "demo_project_001", + "ir": { + "width": 1920, + "height": 1080, + "fps": 30, + "duration": 15000, + "video": [ + { + "id": "video_1", + "src": "https://example.com/video1.mp4", + "start": 0, + "end": 10000, + "in": 2000, + "out": 12000, + "_source_type": "remote_url" + }, + { + "id": "video_2", + "src": "blob:http://localhost:3000/abc-123", + "start": 10000, + "end": 15000, + "in": 0, + "out": 5000, + "_source_type": "blob" + } + ], + "texts": [ + { + "id": "subtitle_1", + "text": "欢迎观看演示视频", + "start": 1000, + "end": 4000, + "style": { + "fontSize": 48, + "color": "#FFFFFF", + "fontFamily": "Arial", + "align": "center" + } + } + ] + }, + "options": { + "quality": "professional", + "codec": "libx264", + "subtitleMode": "hard" + }, + "videoFiles": { + "abc-123": "data:video/mp4;base64,AAAAIGZ0eXBpc29tAAACAGlzb21pc28y..." + } +} +``` + +## 响应格式 (SSE) + +接口返回 Server-Sent Events 流,每个事件包含以下格式: + +``` +data: {"type": "progress", "message": "处理中...", "progress": 0.5} + +``` + +### 事件类型 + +#### 1. start - 开始事件 +```json +{ + "type": "start", + "message": "开始导出...", + "timestamp": "2024-01-01T12:00:00.000Z" +} +``` + +#### 2. progress - 进度事件 +```json +{ + "type": "progress", + "stage": "preparing|stream_copy|uploading", + "message": "当前阶段描述", + "progress": 0.65, + "timestamp": "2024-01-01T12:00:30.000Z" +} +``` + +#### 3. complete - 完成事件 +```json +{ + "type": "complete", + "message": "🎬 高清视频导出完成", + "timestamp": "2024-01-01T12:01:00.000Z", + "file_size": 52428800, + "export_id": "export_abc123", + "quality_mode": "stream_copy", + "download_url": "https://cdn.example.com/video.mp4", + "cloud_storage": true +} +``` + +#### 4. error - 错误事件 +```json +{ + "type": "error", + "message": "导出失败: 文件不存在", + "timestamp": "2024-01-01T12:00:45.000Z" +} +``` + +## 前端集成示例 + +### JavaScript/TypeScript +```typescript +async function exportVideo(exportRequest: ExportRequest) { + const response = await fetch('/api/export/stream', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'text/event-stream' + }, + body: JSON.stringify(exportRequest) + }); + + const reader = response.body?.getReader(); + const decoder = new TextDecoder(); + + 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: ')) { + const data = JSON.parse(line.slice(6)); + handleProgressEvent(data); + } + } + } +} + +function handleProgressEvent(event: any) { + switch (event.type) { + case 'start': + console.log('导出开始'); + break; + case 'progress': + console.log(`进度: ${event.progress * 100}% - ${event.message}`); + break; + case 'complete': + console.log('导出完成:', event.download_url); + break; + case 'error': + console.error('导出失败:', event.message); + break; + } +} +``` + +## 重要注意事项 + +### 1. 视频源处理优先级 +1. **本地文件路径** - 直接使用 +2. **HTTP/HTTPS URL** - 自动下载 +3. **Blob URL** - 需要提供 `videoFiles` 中的 base64 数据 + +### 2. 高质量流复制模式 +- 系统默认启用流复制模式,保持原始视频质量 +- 处理速度提升 10-20 倍 +- 零质量损失 + +### 3. 音频兼容性 +- 自动检测混合音频情况 +- 智能处理有音频/无音频片段的兼容性 + +### 4. 错误处理 +- 无效视频源会被自动跳过 +- 详细的错误信息通过 SSE 实时推送 + +### 5. 云存储集成 +- 支持七牛云自动上传 +- 上传失败时提供本地下载链接 + +## 验证接口 + +在正式导出前,建议先调用验证接口: + +```http +POST /api/export/validate +Content-Type: application/json + +{ + "ir": { /* 同导出接口的ir参数 */ }, + "options": { /* 同导出接口的options参数 */ } +} +``` + +验证接口会检查: +- IR 数据完整性 +- 视频分辨率、帧率、时长 +- 导出选项有效性 +- 返回详细的验证错误信息 \ No newline at end of file diff --git a/docs/剪辑计划.md b/docs/剪辑计划.md new file mode 100644 index 0000000..8bed013 --- /dev/null +++ b/docs/剪辑计划.md @@ -0,0 +1,114 @@ + +剪辑计划完整数据 +{ + "project_id": "3a994c3f-8a98-45e5-be9b-cdf3fabcc335", + "director_intent": "", + "success": true, + "editing_plan": { + "finalized_dialogue_track": { + "final_dialogue_segments": [ + { + "sequence_clip_id": "seq_clip_001", + "source_clip_id": "E1-S1-C01", + "start_timecode": "00:00:00.000", + "end_timecode": "00:00:08.000", + "transcript": "Discipline is a fortress built stone by stone.", + "speaker": "JIN (V.O.)" + }, + { + "sequence_clip_id": "seq_clip_003", + "source_clip_id": "E1-S1-C05", + "start_timecode": "00:00:00.000", + "end_timecode": "00:00:02.000", + "transcript": "But every fortress has a gate.", + "speaker": "JIN (V.O.)" + }, + { + "sequence_clip_id": "seq_clip_005", + "source_clip_id": "E1-S1-C06_C07_Combined", + "start_timecode": "00:00:02.000", + "end_timecode": "00:00:04.000", + "transcript": "Li. To the inner chambers. Go.", + "speaker": "JIN" + }, + { + "sequence_clip_id": "seq_clip_009", + "source_clip_id": "E1-S1-C19_E1-S1-C20", + "start_timecode": "00:00:01.000", + "end_timecode": "00:00:03.000", + "transcript": "And some lessons are taught in blood.", + "speaker": "JIN (V.O.)" + } + ] + }, + "material_classification_results": { + "discarded_footage_list": [ + { + "clip_id": "E1-S1-C15-16_Combined", + "video_url": "https://video-base-imf.oss-ap-southeast-7.aliyuncs.com/uploads/FJ10_5d3d2102-a1a0-44f2-afb1-10ce66690c00-20250916160659.mp4", + "reason": "该素材包含严重AI生成伪影和恐怖谷效应,导致核心内容无法识别,且存在更好的替代品(E1-S1-C13_E1-S1-C14_Sequence的结尾部分可以替代其叙事功能)。" + }, + { + "clip_id": "E1-S1-C17_E1-S1-C18_Combined", + "video_url": "https://video-base-imf.oss-ap-southeast-7.aliyuncs.com/uploads/FJ11_7db3d22f-f7c1-40bb-9b14-cb8b4993adc3-20250916160659.mp4", + "reason": "该素材所有片段均存在严重AI生成缺陷,无法有效传达核心价值,且无法通过后期修复。已在production_suggestions中请求补拍。" + } + ], + "alternative_footage_list": [] + }, + "editing_sequence_plans": [ + { + "version_name": "Final Cut - Action Focus", + "version_summary": "本剪辑方案严格遵循默奇六原则和最高指令,以快节奏、高信息密度和紧张感为核心,构建了一个从宁静到暴力冲突的叙事弧线。优先保留情感和故事驱动的镜头,对物理连贯性Bug采取容忍或修复策略,确保故事连贯性。", + "timeline_clips": [ + { + "sequence_clip_id": "seq_clip_001", + "source_clip_id": "E1-S1-C01", + "video_url": "https://video-base-imf.oss-ap-southeast-7.aliyuncs.com/uploads/FJ1_9a868131-8594-43f1-84a6-f83b2246b91f-20250916160702.mp4", + "corresponding_script_scene_id": "SCENE 1", + "clip_type": "Establishing Shot", + "sequence_start_timecode": "00:00:00.000", + "source_in_timecode": "00:00:00.000", + "source_out_timecode": "00:00:08.000", + "clip_duration_in_sequence": "00:00:08.000", + "transition_from_previous": { + "transition_type": "Fade In", + "transition_duration_ms": 1000, + "audio_sync_offset_ms": 0, + "reason_for_transition": "从黑场淡入,强调宁静的开场,与金大师的画外音完美结合,建立氛围。" + }, + "clip_placement_reasons": { + "prime_directive_justification": "此为场景开场,建立环境和角色,是叙事不可或缺的节拍。", + "core_intent_and_audience_effect": "通过广阔的夜景和金大师的画外音,建立修道院的宁静与纪律,为后续的暴力打破提供强烈对比,引发观众对和平被侵犯的共鸣。", + "emotion_priority_51": "传达宁静、秩序与潜在的脆弱感。", + "story_priority_23": "引入主要角色(李和金),建立场景背景,并以画外音点明主题。", + "rhythm_priority_10": "缓慢的开场节奏,为后续的冲突积蓄力量。", + "eyeline_priority_7": "高角度俯瞰,引导观众总览整个场景。", + "2d_space_priority_5": "静态镜头,无轴线问题。", + "3d_space_priority_4": "空间布局清晰,道具位置合理。", + "lens_language_application": "高角度EWS,强调环境的广阔和人物的渺小,营造史诗感。" + }, + "continuity_correction_details": { + "error_detected_in_audit": true, + "error_type": "Script-to-Picture Mismatch (Camera Movement)", + "error_description": "剧本要求慢速摇臂和摇摄,但素材为静态广角镜头。李的扫地动作重复机械。", + "resolution_strategy_applied": "Tolerate (Sole Coverage) & VFX Suggested", + "details": "尽管运镜不符,但该镜头作为开场建立场景的功能强大,且是唯一覆盖素材。选择容忍其静态运镜,并建议后期VFX修复李的扫地动作以增加自然度。其核心叙事价值(建立宁静)高于运镜缺陷。" + }, + "sound_design_suggestions": [ + { + "sound_type": "Ambient Sound", + "description": "加入微弱的夜间环境音,如风声、远处虫鸣,以增强宁静感。", + "timing_in_clip": "00:00:00.000 - 00:00:08.000", + "intensity_suggestion": "Low" + }, + { + "sound_type": "Voice-over", + "description": "确保画外音清晰、洪亮,与画面氛围匹配。", + "timing_in_clip": "00:00:00.000 - 00:00:08.000", + "intensity_suggestion": "Medium" + } + ], + "visual_enhancement_suggestions": [ + { + "enh \ No newline at end of file diff --git a/docs/请求.md b/docs/请求.md new file mode 100644 index 0000000..eec8617 --- /dev/null +++ b/docs/请求.md @@ -0,0 +1,5 @@ + + + + +{"project_id": "3a994c3f-8a98-45e5-be9b-cdf3fabcc335", "task_id": "9fc9fdfe-ae29-46ad-86e9-93677401dc01", "message": "📨 收到导出请求: {'width': 1920, 'height': 1080, 'fps': 30, 'duration': 70000, 'video': [{'id': '36284a99-ac8e-4b92-b585-8a5a9587fbac', 'src': 'https://video-base-imf.oss-ap-southeast-7.aliyuncs.com/uploads/FJ1_9a868131-8594-43f1-84a6-f83b2246b91f-20250916160702.mp4', 'in': 0, 'out': 8000, 'start': 0, 'trackId': '5dc8e565-151e-4b28-9ab3-1cdd42b46740', 'transform': {'x': 0, 'y': 0, 'scale': 1, 'rotate': 0, 'horizontalFlip': False, 'verticalFlip': False}, 'muted': False, 'actualPath': 'video-1.mp4', 'metadata': {'size': 0, 'duration': 8, 'width': 1280, 'height': 720, 'type': 'video'}}, {'id': '30768bc9-26ff-46df-b19e-ceeffb3367ce', 'src': 'https://video-base-imf.oss-ap-southeast-7.aliyuncs.com/uploads/FJ2_45dbd9ef-622e-4280-aae3-9f4b909394a0-20250916160659.mp4', 'in': 0, 'out': 8000, 'start': 8000, 'trackId': '5dc8e565-151e-4b28-9ab3-1cdd42b46740', 'transform': {'x': 0, 'y': 0, 'scale': 1, 'rotate': 0, 'horizontalFlip': False, 'verticalFlip': False}, 'muted': False, 'actualPath': 'video-3.mp4', 'metadata': {'size': 0, 'duration': 8, 'width': 1280, 'height': 720, 'type': 'video'}}, {'id': '9b3b93de-7bc0-4ddb-997a-c492d04eaf2b', 'src': 'https://video-base-imf.oss-ap-southeast-7.aliyuncs.com/uploads/FJ4_e31a294b-a31f-4d3f-8063-7e6e6c56550b-20250916160700.mp4', 'in': 0, 'out': 2000, 'start': 16000, 'trackId': '5dc8e565-151e-4b28-9ab3-1cdd42b46740', 'transform': {'x': 0, 'y': 0, 'scale': 1, 'rotate': 0, 'horizontalFlip': False, 'verticalFlip': False}, 'muted': False, 'actualPath': 'video-4.mp4', 'metadata': {'size': 0, 'duration': 8, 'width': 1280, 'height': 720, 'type': 'video'}}, {'id': '18cba733-840a-4c65-a4d7-609d26d8af53', 'src': 'https://video-base-imf.oss-ap-southeast-7.aliyuncs.com/uploads/FJ3_c3a115fc-31fa-4d07-87ac-72cecaebb76c-20250916160700.mp4', 'in': 0, 'out': 7000, 'start': 18000, 'trackId': '5dc8e565-151e-4b28-9ab3-1cdd42b46740', 'transform': {'x': 0, 'y': 0, 'scale': 1, 'rotate': 0, 'horizontalFlip': False, 'verticalFlip': False}, 'muted': False, 'actualPath': 'video-5.mp4', 'metadata': {'size': 0, 'duration': 8, 'width': 1280, 'height': 720, 'type': 'video'}}, {'id': '4fa413cb-11fb-4852-8c36-b0f914dc4ddc', 'src': 'https://video-base-imf.oss-ap-southeast-7.aliyuncs.com/uploads/FJ5_bb89414f-1a25-43bb-954f-f25a1d052f8f-20250916160658.mp4', 'in': 0, 'out': 4000, 'start': 25000, 'trackId': '5dc8e565-151e-4b28-9ab3-1cdd42b46740', 'transform': {'x': 0, 'y': 0, 'scale': 1, 'rotate': 0, 'horizontalFlip': False, 'verticalFlip': False}, 'muted': False, 'actualPath': 'video-6.mp4', 'metadata': {'size': 0, 'duration': 8, 'width': 1280, 'height': 720, 'type': 'video'}}, {'id': '8b6bb6ff-5861-48b4-97ec-b13adccb97bd', 'src': 'https://video-base-imf.oss-ap-southeast-7.aliyuncs.com/uploads/FJ6_c05d9bfc-3155-4fc3-a2b2-54fb09e3f9de-20250916160759.mp4', 'in': 0, 'out': 6000, 'start': 29000, 'trackId': '5dc8e565-151e-4b28-9ab3-1cdd42b46740', 'transform': {'x': 0, 'y': 0, 'scale': 1, 'rotate': 0, 'horizontalFlip': False, 'verticalFlip': False}, 'muted': False, 'actualPath': 'video-7.mp4', 'metadata': {'size': 0, 'duration': 8, 'width': 1280, 'height': 720, 'type': 'video'}}, {'id': 'd75618fe-6c76-4386-b85f-15300072dea3', 'src': 'https://video-base-imf.oss-ap-southeast-7.aliyuncs.com/uploads/FJ7_ff1cd68b-54d7-435c-aeda-a3ebfd57e60a-20250916160659.mp4', 'in': 0, 'out': 8000, 'start': 35000, 'trackId': '5dc8e565-151e-4b28-9ab3-1cdd42b46740', 'transform': {'x': 0, 'y': 0, 'scale': 1, 'rotate': 0, 'horizontalFlip': False, 'verticalFlip': False}, 'muted': False, 'actualPath': 'video-9.mp4', 'metadata': {'size': 0, 'duration': 8, 'width': 1280, 'height': 720, 'type': 'video'}}, {'id': '685357ae-1483-4055-b42d-362804a721f1', 'src': 'https://video-base-imf.oss-ap-southeast-7.aliyuncs.com/uploads/FJ8_489a9c54-c9f3-4946-98ff-fa0fbb2b0ebc-20250916160658.mp4', 'in': 0, 'out': 4000, 'start': 43000, 'trackId': '5dc8e565-151e-4b28-9ab3-1cdd42b46740', 'transform': {'x': 0, 'y': 0, 'scale': 1, 'rotate': 0, 'horizontalFlip': False, 'verticalFlip': False}, 'muted': False, 'actualPath': 'video-10.mp4', 'metadata': {'size': 0, 'duration': 8, 'width': 1280, 'height': 720, 'type': 'video'}}, {'id': '5103ca45-ad1b-4e6d-9210-d3625b686366', 'src': 'https://video-base-imf.oss-ap-southeast-7.aliyuncs.com/uploads/FJ9_45c26a3f-be13-4758-9e65-333bc5cccbce-20250916160659.mp4', 'in': 0, 'out': 2000, 'start': 47000, 'trackId': '5dc8e565-151e-4b28-9ab3-1cdd42b46740', 'transform': {'x': 0, 'y': 0, 'scale': 1, 'rotate': 0, 'horizontalFlip': False, 'verticalFlip': False}, 'muted': False, 'actualPath': 'video-11.mp4', 'metadata': {'size': 0, 'duration': 8, 'width': 1280, 'height': 720, 'type': 'video'}}, {'id': '34701b47-19e8-4ca7-808c-9b6d90951c69', 'src': 'https://video-base-imf.oss-ap-southeast-7.aliyuncs.com/uploads/FJ10_5d3d2102-a1a0-44f2-afb1-10ce66690c00-20250916160659.mp4', 'in': 0, 'out': 8000, 'start': 49000, 'trackId': '5dc8e565-151e-4b28-9ab3-1cdd42b46740', 'transform': {'x': 0, 'y': 0, 'scale': 1, 'rotate': 0, 'horizontalFlip': False, 'verticalFlip': False}, 'muted': False, 'actualPath': 'video-12.mp4', 'metadata': {'size': 0, 'duration': 8, 'width': 1280, 'height': 720, 'type': 'video'}}, {'id': '6c306de0-64b0-4932-82f9-2f368fbaa005', 'src': 'https://video-base-imf.oss-ap-southeast-7.aliyuncs.com/uploads/FJ11_7db3d22f-f7c1-40bb-9b14-cb8b4993adc3-20250916160659.mp4', 'in': 0, 'out': 8000, 'start': 57000, 'trackId': '5dc8e565-151e-4b28-9ab3-1cdd42b46740', 'transform': {'x': 0, 'y': 0, 'scale': 1, 'rotate': 0, 'horizontalFlip': False, 'verticalFlip': False}, 'muted': False, 'actualPath': 'video-13.mp4', 'metadata': {'size': 0, 'duration': 8, 'width': 1280, 'height': 720, 'type': 'video'}}, {'id': '9a374a4a-af20-4ba2-b97a-7be007ce8de6', 'src': 'https://video-base-imf.oss-ap-southeast-7.aliyuncs.com/uploads/FJ12_1544f170-8722-4512-b45f-390953cf6d18-20250916160659.mp4', 'in': 0, 'out': 3000, 'start': 65000, 'trackId': '5dc8e565-151e-4b28-9ab3-1cdd42b46740', 'transform': {'x': 0, 'y': 0, 'scale': 1, 'rotate': 0, 'horizontalFlip': False, 'verticalFlip': False}, 'muted': False, 'actualPath': 'video-14.mp4', 'metadata': {'size': 0, 'duration': 8, 'width': 1280, 'height': 720, 'type': 'video'}}, {'id': '63194ed8-0636-49fc-964c-f7412e0b3fce', 'src': 'https://video-base-imf.oss-ap-southeast-7.aliyuncs.com/uploads/FJ13_bcc5652c-c965-4ac7-a012-5e9b11beca28-20250916160701.mp4', 'in': 0, 'out': 2000, 'start': 68000, 'trackId': '5dc8e565-151e-4b28-9ab3-1cdd42b46740', 'transform': {'x': 0, 'y': 0, 'scale': 1, 'rotate': 0, 'horizontalFlip': False, 'verticalFlip': False}, 'muted': False, 'actualPath': 'video-15.mp4', 'metadata': {'size': 0, 'duration': 8, 'width': 1280, 'height': 720, 'type': 'video'}}], 'audio': [], 'texts': [], 'transitions': []}"} \ No newline at end of file diff --git a/utils/export-service.ts b/utils/export-service.ts new file mode 100644 index 0000000..a286c77 --- /dev/null +++ b/utils/export-service.ts @@ -0,0 +1,740 @@ +import { notification } from 'antd'; +import { downloadVideo } from './tools'; +import { getGenerateEditPlan } from '@/api/video_flow'; + +/** + * 导出服务 - 封装视频导出相关功能 + * 支持流式导出、进度轮询、失败重试机制 + */ + +// 导出请求接口 +interface ExportRequest { + project_id: string; + ir: IRData; + options: ExportOptions; +} + +// IR数据结构 +interface IRData { + width: number; + height: number; + fps: number; + duration: number; + video: VideoElement[]; + texts: TextElement[]; + audio: any[]; + transitions: TransitionElement[]; +} + +// 视频元素结构 +interface VideoElement { + id: string; + src: string; + start: number; + end: number; + in: number; + out: number; + _source_type: 'remote_url' | 'local'; +} + +// 文本元素结构 +interface TextElement { + id: string; + text: string; + start: number; + end: number; + style: { + fontFamily: string; + fontSize: number; + color: string; + backgroundColor: string; + fontWeight: 'normal' | 'bold'; + fontStyle: 'normal' | 'italic'; + align: 'left' | 'center' | 'right'; + shadow: boolean; + }; +} + +// 转场元素结构 +interface TransitionElement { + id: string; + type: string; + duration: number; + start: number; + end: number; +} + +// 导出选项 +interface ExportOptions { + quality: 'preview' | 'standard' | 'professional'; + codec: string; + subtitleMode: 'hard' | 'soft'; +} + +// 导出服务配置 +interface ExportServiceConfig { + maxRetries?: number; + pollInterval?: number; + apiBaseUrl?: string; +} + +// 导出结果 +interface ExportResult { + task_id: string; + status: string; + video_url?: string; + file_size?: number; + export_id?: string; + quality_mode?: string; + watermark_status?: string; + upload_time?: string; +} + +/** + * 视频导出服务类 + */ +export class VideoExportService { + private config: Required; + private cachedExportRequest: ExportRequest | null = null; + + constructor(config: ExportServiceConfig = {}) { + this.config = { + maxRetries: config.maxRetries || 3, + pollInterval: config.pollInterval || 5000, // 5秒轮询 + apiBaseUrl: config.apiBaseUrl || 'https://smartcut.api.movieflow.ai' + }; + } + + /** + * 辅助函数:将时间码转换为毫秒 + */ + private parseTimecodeToMs(timecode: string): number { + // 处理两种时间码格式: + // 1. "00:00:08.000" (时:分:秒.毫秒) - 剪辑计划格式 + // 2. "00:00:08:00" (时:分:秒:帧) - 传统时间码格式 + + if (timecode.includes('.')) { + // 格式: "00:00:08.000" + const [timePart, msPart] = timecode.split('.'); + const [hours, minutes, seconds] = timePart.split(':').map(Number); + const milliseconds = parseInt(msPart.padEnd(3, '0').slice(0, 3)); + + return (hours * 3600 + minutes * 60 + seconds) * 1000 + milliseconds; + } else { + // 格式: "00:00:08:00" (时:分:秒:帧) + const parts = timecode.split(':'); + if (parts.length !== 4) return 0; + + const hours = parseInt(parts[0]) || 0; + const minutes = parseInt(parts[1]) || 0; + const seconds = parseInt(parts[2]) || 0; + const frames = parseInt(parts[3]) || 0; + + // 假设30fps + const totalSeconds = hours * 3600 + minutes * 60 + seconds + frames / 30; + return Math.round(totalSeconds * 1000); + } + } + + /** + * 基于剪辑计划生成导出数据 + */ + private async generateExportDataFromEditingPlan(episodeId: string, taskObject: any): Promise<{ + exportRequest: ExportRequest; + editingPlan: any; + }> { + console.log('🎬 开始获取剪辑计划...'); + + try { + // 1. 首先获取剪辑计划 + const editPlanResponse = await getGenerateEditPlan({ project_id: episodeId }); + + if (!editPlanResponse.successful || !editPlanResponse.data.editing_plan) { + throw new Error('获取剪辑计划失败: ' + editPlanResponse.message); + } + + const editingPlan = editPlanResponse.data.editing_plan; + console.log('📋 获取到剪辑计划:', editingPlan); + + // 2. 检查是否有可用的视频数据 + if (!taskObject.videos?.data || taskObject.videos.data.length === 0) { + throw new Error('没有可用的视频数据'); + } + + // 3. 过滤出已完成的视频 + const completedVideos = taskObject.videos.data.filter((video: any) => + video.video_status === 1 && video.urls && video.urls.length > 0 + ); + + if (completedVideos.length === 0) { + throw new Error('没有已完成的视频片段'); + } + + console.log(`📊 找到 ${completedVideos.length} 个已完成的视频片段`); + + // 4. 根据剪辑计划转换视频数据 - 符合API文档的VideoElement格式 + const defaultClipDuration = 8000; // 默认8秒每个片段(毫秒) + let currentStartTime = 0; // 当前时间轴位置 + + // 构建视频元素数组 - 严格按照API文档的VideoElement结构 + let videoElements: VideoElement[]; + + if (editingPlan.editing_sequence_plans && editingPlan.editing_sequence_plans.length > 0) { + // 使用剪辑计划中的时间线信息 + const timelineClips = editingPlan.editing_sequence_plans[0].timeline_clips || []; + console.log('🎞️ 使用剪辑计划中的时间线信息:', timelineClips); + + videoElements = timelineClips.map((clip: any, index: number) => { + // 查找对应的视频数据 + const matchedVideo = completedVideos.find((video: any) => + video.video_id === clip.source_clip_id || + video.urls?.some((url: string) => url === clip.video_url) + ); + + // 优先使用剪辑计划中的video_url,其次使用匹配视频的URL + const videoUrl = clip.video_url || matchedVideo?.urls?.[0]; + + // 解析剪辑计划中的精确时间码 + const sequenceStartMs = this.parseTimecodeToMs(clip.sequence_start_timecode || "00:00:00.000"); + const sourceInMs = this.parseTimecodeToMs(clip.source_in_timecode || "00:00:00.000"); + const sourceOutMs = this.parseTimecodeToMs(clip.source_out_timecode || "00:00:08.000"); + const clipDurationMs = this.parseTimecodeToMs(clip.clip_duration_in_sequence || "00:00:08.000"); + + console.log(`🎬 处理片段 ${clip.sequence_clip_id}:`, { + video_url: videoUrl, + sequence_start: sequenceStartMs, + source_in: sourceInMs, + source_out: sourceOutMs, + duration: clipDurationMs + }); + + // 严格按照API文档的VideoElement结构 + const element: VideoElement = { + id: clip.sequence_clip_id || matchedVideo?.video_id || `video_${index + 1}`, + src: videoUrl, + start: currentStartTime, // 在时间轴上的开始时间 + end: currentStartTime + clipDurationMs, // 在时间轴上的结束时间 + in: sourceInMs, // 视频内部开始时间 + out: sourceOutMs, // 视频内部结束时间 + _source_type: videoUrl?.startsWith('http') ? 'remote_url' : 'local' + }; + + currentStartTime += clipDurationMs; + return element; + }); + } else { + // 如果没有具体的时间线信息,使用视频数据生成 + console.log('📹 使用视频数据生成时间线'); + videoElements = completedVideos.map((video: any, index: number) => { + const videoUrl = video.urls![0]; + + // 严格按照API文档的VideoElement结构 + const element: VideoElement = { + id: video.video_id || `video_${index + 1}`, + src: videoUrl, + start: currentStartTime, + end: currentStartTime + defaultClipDuration, // 添加end字段 + in: 0, + out: defaultClipDuration, + _source_type: videoUrl.startsWith('http') ? 'remote_url' : 'local' + }; + + currentStartTime += defaultClipDuration; + return element; + }); + } + + const totalDuration = currentStartTime; + + // 处理转场效果 + const transitions: TransitionElement[] = []; + if (editingPlan.editing_sequence_plans?.[0]?.timeline_clips) { + const timelineClips = editingPlan.editing_sequence_plans[0].timeline_clips; + + for (let i = 0; i < timelineClips.length; i++) { + const clip = timelineClips[i]; + if (clip.transition_from_previous && i > 0) { + const transition: TransitionElement = { + id: `transition_${i}`, + type: clip.transition_from_previous.transition_type || 'Cut', + duration: clip.transition_from_previous.transition_duration_ms || 0, + start: this.parseTimecodeToMs(clip.sequence_start_timecode || "00:00:00.000") - (clip.transition_from_previous.transition_duration_ms || 0), + end: this.parseTimecodeToMs(clip.sequence_start_timecode || "00:00:00.000") + }; + transitions.push(transition); + } + } + } + + // 处理字幕/对话轨道 + const texts: TextElement[] = []; + if (editingPlan.finalized_dialogue_track?.final_dialogue_segments) { + editingPlan.finalized_dialogue_track.final_dialogue_segments.forEach((dialogue: any, index: number) => { + const textElement: TextElement = { + id: dialogue.sequence_clip_id || `text_${index + 1}`, + text: dialogue.transcript, + start: this.parseTimecodeToMs(dialogue.start_timecode || "00:00:00.000"), + end: this.parseTimecodeToMs(dialogue.end_timecode || "00:00:02.000"), + style: { + fontFamily: 'Arial', + fontSize: 40, + color: '#FFFFFF', + backgroundColor: 'transparent', + fontWeight: 'normal', + fontStyle: 'normal', + align: 'center', + shadow: true + } + }; + texts.push(textElement); + }); + } + + // 构建符合API文档的IR数据结构 + const irData: IRData = { + width: 1920, + height: 1080, + fps: 30, + duration: totalDuration, + video: videoElements, + texts: texts, // 从剪辑计划中提取的字幕 + audio: [], // 可选字段,空数组 + transitions: transitions // 从剪辑计划中提取的转场 + }; + + // 构建完整的导出请求数据 - 符合API文档的ExportRequest格式 + const exportRequest: ExportRequest = { + project_id: episodeId, + ir: irData, + options: { + quality: 'standard', + codec: 'libx264', + subtitleMode: 'hard' + } + }; + + return { + exportRequest, + editingPlan: editingPlan // 保存剪辑计划信息用于调试 + }; + + } catch (error) { + console.error('❌ 生成导出数据失败:', error); + throw error; + } + } + + /** + * 调用导出流接口的核心函数 + */ + private async callExportStreamAPI(exportRequest: ExportRequest, attemptNumber: number = 1): Promise { + console.log(`🚀 第${attemptNumber}次调用流式导出接口...`); + console.log('📋 发送的完整导出请求数据:', JSON.stringify(exportRequest, null, 2)); + + const response = await fetch(`${this.config.apiBaseUrl}/api/export/stream`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'text/event-stream' + }, + body: JSON.stringify(exportRequest) + }); + + console.log('📡 导出接口响应状态:', response.status, response.statusText); + + if (!response.ok) { + const errorText = await response.text(); + console.error('❌ 导出接口错误响应:', errorText); + throw new Error(`HTTP error! status: ${response.status}, message: ${errorText}`); + } + + // 处理SSE流式响应 + console.log('📺 开始处理流式响应...'); + const reader = response.body?.getReader(); + const decoder = new TextDecoder(); + let eventCount = 0; + let finalResult = null; + let detectedTaskId = null; // 用于收集任务ID + + if (reader) { + 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 eventData = JSON.parse(line.slice(6)); + eventCount++; + console.log(`📨 SSE事件 #${eventCount}:`, eventData); + + // 尝试从任何事件中提取任务ID + if (eventData.export_id || eventData.task_id) { + detectedTaskId = eventData.export_id || eventData.task_id; + console.log('🔍 在SSE事件中发现任务ID:', detectedTaskId); + } + + // 处理不同类型的事件,按照API文档规范 + switch (eventData.type) { + case 'start': + console.log('🚀 导出开始:', eventData.message); + // start事件中可能包含任务ID + if (eventData.export_id || eventData.task_id) { + detectedTaskId = eventData.export_id || eventData.task_id; + console.log('📋 从start事件获取任务ID:', detectedTaskId); + } + break; + + case 'progress': + const progressPercent = Math.round((eventData.progress || 0) * 100); + console.log(`📊 导出进度: ${progressPercent}% - ${eventData.stage || 'processing'} - ${eventData.message}`); + break; + + case 'complete': + console.log('🎉 导出完成!', eventData); + finalResult = eventData; + // 确保最终结果包含任务ID + if (detectedTaskId && !finalResult.export_id && !finalResult.task_id) { + finalResult.export_id = detectedTaskId; + } + // 导出完成,退出循环 + return finalResult; + + case 'error': + throw new Error(`导出失败: ${eventData.message}`); + + default: + console.log('📋 其他事件:', eventData); + } + } catch (parseError) { + console.warn('⚠️ 解析SSE事件失败:', line, parseError); + } + } + } + } + } finally { + reader.releaseLock(); + } + } + + // 如果有检测到的任务ID,确保添加到最终结果中 + if (detectedTaskId && finalResult && !finalResult.export_id && !finalResult.task_id) { + finalResult.export_id = detectedTaskId; + console.log('📋 将检测到的任务ID添加到最终结果:', detectedTaskId); + } + + return finalResult; + } + + /** + * 轮询导出进度的函数 + * - status: 'completed' 时立即停止轮询并返回结果 + * - status: 'failed' 时抛出 EXPORT_FAILED 错误,触发重新调用 api/export/stream + * - 其他状态继续轮询,最多轮询10分钟(5秒间隔) + */ + private async pollExportProgress(taskId: string): Promise { + console.log('🔄 开始轮询导出进度,任务ID:', taskId); + const maxAttempts = 120; // 最多轮询10分钟(5秒间隔) + let attempts = 0; + + while (attempts < maxAttempts) { + try { + const progressUrl = `${this.config.apiBaseUrl}/api/export/task/${taskId}/progress`; + console.log(`📊 第${attempts + 1}次查询进度:`, progressUrl); + + const response = await fetch(progressUrl); + + if (!response.ok) { + throw new Error(`进度查询失败: ${response.status} ${response.statusText}`); + } + + const progressData = await response.json(); + console.log('📈 进度数据:', progressData); + + // 根据API返回的数据结构处理 + const { status, progress } = progressData; + + if (status === 'completed') { + console.log('🎉 导出任务完成!', progress); + return { + task_id: taskId, + status: status, + video_url: progress?.video_url, + file_size: progress?.file_size, + export_id: progress?.export_id, + quality_mode: progress?.quality_mode, + watermark_status: progress?.watermark_status, + upload_time: progress?.upload_time + }; + } else if (status === 'failed') { + console.log('❌ 导出任务失败,需要重新调用 api/export/stream'); + throw new Error(`EXPORT_FAILED: ${progress?.message || '导出任务失败'}`); + } else if (status === 'error') { + throw new Error(`导出任务错误: ${progress?.message || '未知错误'}`); + } else { + // 任务仍在进行中 + const percentage = progress?.percentage || 0; + const message = progress?.message || '处理中...'; + const stage = progress?.stage || 'processing'; + + console.log(`⏳ 导出进度: ${percentage}% - ${stage} - ${message}`); + + // 等待5秒后继续轮询 + await new Promise(resolve => setTimeout(resolve, this.config.pollInterval)); + attempts++; + } + } catch (error) { + console.error(`❌ 第${attempts + 1}次进度查询失败:`, error); + attempts++; + + // 如果不是最后一次尝试,等待5秒后重试 + if (attempts < maxAttempts) { + await new Promise(resolve => setTimeout(resolve, this.config.pollInterval)); + } + } + } + + throw new Error('导出进度查询超时,请稍后手动检查'); + } + + /** + * 主要的导出方法 - 支持重试机制 + */ + public async exportVideo(episodeId: string, taskObject: any): Promise { + let currentAttempt = 1; + + try { + // 重试循环 + while (currentAttempt <= this.config.maxRetries) { + try { + // 第一步:获取剪辑计划(只在第一次尝试时获取) + let exportRequest: ExportRequest; + if (currentAttempt === 1) { + console.log('🎬 步骤1: 获取剪辑计划...'); + const { exportRequest: generatedExportRequest, editingPlan: generatedEditingPlan } = await this.generateExportDataFromEditingPlan(episodeId, taskObject); + exportRequest = generatedExportRequest; + + console.log('📤 生成的导出请求数据:', exportRequest); + console.log(`📊 包含 ${exportRequest.ir.video.length} 个视频片段,总时长: ${exportRequest.ir.duration}ms`); + console.log('🎬 使用的剪辑计划:', generatedEditingPlan); + + // 缓存exportRequest以便重试时使用 + this.cachedExportRequest = exportRequest; + } else { + // 重试时使用缓存的请求数据 + exportRequest = this.cachedExportRequest!; + console.log(`🔄 第${currentAttempt}次重试,使用缓存的导出请求数据`); + } + + // 第二步:调用导出接口 + console.log(`🚀 步骤2: 第${currentAttempt}次调用流式导出接口...`); + const result = await this.callExportStreamAPI(exportRequest, currentAttempt); + + console.log('✅ 导出接口调用成功'); + console.log('🔍 SSE最终结果详情:', JSON.stringify(result, null, 2)); + + // 尝试获取任务ID进行轮询 + let taskId = null; + + // 方法1: 从SSE结果中获取 + if (result?.export_id || result?.task_id) { + taskId = result.export_id || result.task_id; + console.log('📋 从SSE结果中获取到任务ID:', taskId); + } + + // 如果没有任务ID,无法进行轮询 + if (!taskId) { + console.log('⚠️ SSE结果中未找到任务ID,无法进行进度轮询'); + + // 显示警告通知 + notification.warning({ + message: `第${currentAttempt}次导出接口调用成功`, + description: 'SSE流中未找到任务ID,无法进行进度轮询。请检查API返回数据结构。', + placement: 'topRight', + duration: 8 + }); + + // 如果SSE中直接有完整结果,直接处理 + if (result?.download_url || result?.video_url) { + const downloadUrl = result.download_url || result.video_url; + console.log('📥 直接从SSE结果下载视频:', downloadUrl); + await downloadVideo(downloadUrl); + + notification.success({ + message: '视频下载完成!', + description: result?.file_size + ? `文件大小: ${(result.file_size / 1024 / 1024).toFixed(2)}MB` + : '视频已成功下载到本地', + placement: 'topRight', + duration: 8 + }); + } + + return result; + } + + // 如果有任务ID,开始轮询进度 + console.log('🔄 开始轮询导出进度,任务ID:', taskId); + + try { + const finalExportResult = await this.pollExportProgress(taskId); + + // 导出成功 + console.log('🎉 导出成功完成!'); + + // 显示最终成功通知 + notification.success({ + message: `导出成功!(第${currentAttempt}次尝试)`, + description: `文件大小: ${(finalExportResult.file_size! / 1024 / 1024).toFixed(2)}MB,正在下载到本地...`, + placement: 'topRight', + duration: 8 + }); + + // 自动下载视频 + if (finalExportResult.video_url) { + console.log('📥 开始下载视频:', finalExportResult.video_url); + await downloadVideo(finalExportResult.video_url); + console.log('✅ 视频下载完成'); + } + + // 清除缓存的请求数据 + this.cachedExportRequest = null; + + return finalExportResult; + + } catch (pollError) { + console.error(`❌ 第${currentAttempt}次轮询进度失败:`, pollError); + + // 检查是否是导出失败错误(需要重新调用 api/export/stream) + const isExportFailed = pollError instanceof Error && pollError.message.startsWith('EXPORT_FAILED:'); + + if (isExportFailed) { + console.log(`❌ 第${currentAttempt}次导出任务失败(status: 'failed'),需要重新调用 api/export/stream`); + + // 如果还有重试次数,继续重试 + if (currentAttempt < this.config.maxRetries) { + console.log(`🔄 准备第${currentAttempt + 1}次重试(重新调用 api/export/stream)...`); + + notification.warning({ + message: `第${currentAttempt}次导出失败`, + description: `导出状态: failed。正在准备第${currentAttempt + 1}次重试...`, + placement: 'topRight', + duration: 5 + }); + + currentAttempt++; + continue; // 继续重试循环,重新调用 api/export/stream + } else { + // 已达到最大重试次数 + throw new Error(`导出失败,已重试${this.config.maxRetries}次。最后状态: failed`); + } + } else { + // 其他轮询错误(网络错误等) + if (currentAttempt < this.config.maxRetries) { + console.log(`🔄 轮询失败,准备第${currentAttempt + 1}次重试...`); + + notification.warning({ + message: `第${currentAttempt}次轮询失败`, + description: `${pollError instanceof Error ? pollError.message : '未知错误'}。正在准备第${currentAttempt + 1}次重试...`, + placement: 'topRight', + duration: 5 + }); + + currentAttempt++; + continue; // 继续重试循环 + } else { + // 已达到最大重试次数,回退到SSE结果 + console.log('❌ 已达到最大重试次数,回退到SSE结果'); + + notification.error({ + message: '轮询重试失败', + description: `已重试${this.config.maxRetries}次仍然失败。${pollError instanceof Error ? pollError.message : '未知错误'}`, + placement: 'topRight', + duration: 10 + }); + + // 回退到SSE结果 + if (result?.download_url || result?.video_url) { + const downloadUrl = result.download_url || result.video_url; + console.log('📥 回退到SSE结果下载视频:', downloadUrl); + await downloadVideo(downloadUrl); + } + + // 清除缓存的请求数据 + this.cachedExportRequest = null; + + throw pollError; + } + } + } + + } catch (attemptError) { + console.error(`❌ 第${currentAttempt}次尝试失败:`, attemptError); + + // 如果还有重试次数,继续重试 + if (currentAttempt < this.config.maxRetries) { + console.log(`🔄 第${currentAttempt}次尝试失败,准备第${currentAttempt + 1}次重试...`); + + notification.warning({ + message: `第${currentAttempt}次导出尝试失败`, + description: `${attemptError instanceof Error ? attemptError.message : '未知错误'}。正在准备第${currentAttempt + 1}次重试...`, + placement: 'topRight', + duration: 5 + }); + + currentAttempt++; + continue; // 继续重试循环 + } else { + // 已达到最大重试次数 + throw attemptError; + } + } + } + + // 如果退出循环还没有成功,抛出错误 + throw new Error(`导出失败,已重试${this.config.maxRetries}次`); + + } catch (error) { + console.error('❌ 视频导出最终失败:', error); + + // 清除缓存的请求数据 + this.cachedExportRequest = null; + + // 显示最终错误通知 + notification.error({ + message: '视频导出失败', + description: `经过${this.config.maxRetries}次尝试后仍然失败:${error instanceof Error ? error.message : '未知错误'}`, + placement: 'topRight', + duration: 10 + }); + + throw error; + } + } +} + +// 创建默认的导出服务实例 +export const videoExportService = new VideoExportService({ + maxRetries: 3, + pollInterval: 5000, // 5秒轮询间隔 + apiBaseUrl: 'https://smartcut.api.movieflow.ai' +}); + +/** + * 便捷的导出函数 + */ +export async function exportVideoWithRetry(episodeId: string, taskObject: any): Promise { + return videoExportService.exportVideo(episodeId, taskObject); +} + +/** + * 测试轮询逻辑的函数(开发调试用) + */ +export async function testPollingLogic(taskId: string): Promise { + console.log('🧪 测试轮询逻辑,任务ID:', taskId); + return videoExportService['pollExportProgress'](taskId); +}