forked from 77media/video-flow
自动剪辑调用
This commit is contained in:
parent
43bf001e76
commit
08f5c83396
119
docs/3.md
Normal file
119
docs/3.md
Normal file
@ -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
|
||||||
322
docs/API_EXPORT_STREAM_GUIDE.md
Normal file
322
docs/API_EXPORT_STREAM_GUIDE.md
Normal file
@ -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<string, string>; // 视频文件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 数据完整性
|
||||||
|
- 视频分辨率、帧率、时长
|
||||||
|
- 导出选项有效性
|
||||||
|
- 返回详细的验证错误信息
|
||||||
114
docs/剪辑计划.md
Normal file
114
docs/剪辑计划.md
Normal file
@ -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
|
||||||
5
docs/请求.md
Normal file
5
docs/请求.md
Normal file
File diff suppressed because one or more lines are too long
740
utils/export-service.ts
Normal file
740
utils/export-service.ts
Normal file
@ -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<ExportServiceConfig>;
|
||||||
|
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<any> {
|
||||||
|
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<ExportResult> {
|
||||||
|
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<ExportResult> {
|
||||||
|
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<ExportResult> {
|
||||||
|
return videoExportService.exportVideo(episodeId, taskObject);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试轮询逻辑的函数(开发调试用)
|
||||||
|
*/
|
||||||
|
export async function testPollingLogic(taskId: string): Promise<ExportResult> {
|
||||||
|
console.log('🧪 测试轮询逻辑,任务ID:', taskId);
|
||||||
|
return videoExportService['pollExportProgress'](taskId);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user