自动剪辑调用

This commit is contained in:
qikongjian 2025-09-16 22:09:34 +08:00
parent 43bf001e76
commit 08f5c83396
5 changed files with 1300 additions and 0 deletions

119
docs/3.md Normal file
View 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

View 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
View 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

File diff suppressed because one or more lines are too long

740
utils/export-service.ts Normal file
View 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
* - 105
*/
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);
}