瀏覽代碼

增加对口型

黎海 2 月之前
父節點
當前提交
eff11339fc

+ 25 - 0
README.md

@@ -11,6 +11,31 @@
11 11
 - 生成唇形同步动画
12 12
 - 导出和分享功能
13 13
 
14
+## 对口型功能
15
+
16
+该功能允许用户根据已上传的音频和视频文件生成口型同步的视频。
17
+
18
+### 功能特点
19
+
20
+1. **对口型生成**:将音频与视频文件结合,生成口型同步的视频
21
+2. **进度显示**:实时显示对口型生成的进度
22
+3. **结果预览**:完成后直接在界面中预览生成的视频
23
+4. **结果保存**:将生成的视频保存到项目中
24
+
25
+### 使用方法
26
+
27
+1. 上传项目的音频文件和参考视频文件
28
+2. 在项目详情页点击"生成对口型"按钮
29
+3. 等待处理完成(过程可能需要几分钟)
30
+4. 生成完成后,可以直接在页面上预览和使用对口型视频
31
+
32
+### 技术实现
33
+
34
+- 通过 Coze API 上传音频和视频文件获取可访问链接
35
+- 调用对口型 API 提交音频和视频 URL 进行处理
36
+- 通过轮询方式获取处理进度和最终结果
37
+- 将结果保存到项目数据库中并展示到界面
38
+
14 39
 ## 开发环境设置
15 40
 
16 41
 ### 前提条件

+ 94 - 1
src/components/CozeApiSettings.js

@@ -1,7 +1,9 @@
1 1
 import React, { useState, useEffect } from 'react';
2
-import { Form, Button, Card, Container, Row, Col, Alert, Tab, Tabs, InputGroup } from 'react-bootstrap';
2
+import { Form, Button, Card, Container, Row, Col, Alert, Tab, Tabs, InputGroup, Spinner } from 'react-bootstrap';
3 3
 import { saveCozeApiToken, getCozeApiToken, saveWorkflowIds, getWorkflowIds } from '../utils/storageUtils';
4 4
 import { WORKFLOW_DISPLAY_NAMES, hasValidToken } from '../utils/cozeConfig';
5
+import { getLipSyncServer, saveLipSyncServer } from '../utils/lipSyncConfig';
6
+import lipSyncService from '../services/lipSyncService';
5 7
 import { toast } from 'react-toastify';
6 8
 
7 9
 /**
@@ -15,6 +17,9 @@ const CozeApiSettings = ({ onSave, isInModal = true }) => {
15 17
   const [activeTab, setActiveTab] = useState('token');
16 18
   const [hasChanges, setHasChanges] = useState(false);
17 19
   const [errorMessage, setErrorMessage] = useState('');
20
+  const [lipSyncServerUrl, setLipSyncServerUrl] = useState('');
21
+  const [testing, setTesting] = useState(false);
22
+  const [testResult, setTestResult] = useState(null);
18 23
 
19 24
   // 加载已存储的设置
20 25
   useEffect(() => {
@@ -28,6 +33,11 @@ const CozeApiSettings = ({ onSave, isInModal = true }) => {
28 33
       setWorkflowIds(storedWorkflowIds);
29 34
     }
30 35
     
36
+    const storedLipSyncServer = getLipSyncServer();
37
+    if (storedLipSyncServer) {
38
+      setLipSyncServerUrl(storedLipSyncServer);
39
+    }
40
+    
31 41
     setHasChanges(false);
32 42
   }, []);
33 43
 
@@ -45,6 +55,40 @@ const CozeApiSettings = ({ onSave, isInModal = true }) => {
45 55
     }));
46 56
     setHasChanges(true);
47 57
   };
58
+  
59
+  // 处理对口型服务器地址变更
60
+  const handleLipSyncServerChange = (e) => {
61
+    setLipSyncServerUrl(e.target.value);
62
+    setHasChanges(true);
63
+    // 清除测试结果
64
+    setTestResult(null);
65
+  };
66
+  
67
+  // 测试对口型服务器连接
68
+  const testLipSyncServer = async () => {
69
+    setTesting(true);
70
+    setTestResult(null);
71
+    
72
+    try {
73
+      const result = await lipSyncService.testServer(lipSyncServerUrl);
74
+      
75
+      setTestResult(result);
76
+      
77
+      if (result.success) {
78
+        toast.success(result.message);
79
+      } else {
80
+        toast.error(result.message);
81
+      }
82
+    } catch (error) {
83
+      setTestResult({
84
+        success: false,
85
+        message: `测试失败: ${error.message}`
86
+      });
87
+      toast.error(`测试失败: ${error.message}`);
88
+    } finally {
89
+      setTesting(false);
90
+    }
91
+  };
48 92
 
49 93
   // 保存设置
50 94
   const handleSave = () => {
@@ -59,6 +103,9 @@ const CozeApiSettings = ({ onSave, isInModal = true }) => {
59 103
       // 保存工作流ID
60 104
       saveWorkflowIds(workflowIds);
61 105
       
106
+      // 保存对口型服务器配置
107
+      saveLipSyncServer(lipSyncServerUrl);
108
+      
62 109
       setHasChanges(false);
63 110
       toast.success('设置已保存');
64 111
       
@@ -153,6 +200,52 @@ const CozeApiSettings = ({ onSave, isInModal = true }) => {
153 200
           </p>
154 201
           {renderWorkflowIdInputs()}
155 202
         </Tab>
203
+        
204
+        <Tab eventKey="lipSyncServer" title="对口型服务器">
205
+          <p className="text-muted mb-3">
206
+            配置对口型服务器地址。请正确填写服务器地址,否则对口型功能将无法使用。
207
+          </p>
208
+          
209
+          <Form.Group className="mb-3">
210
+            <Form.Label>服务器地址</Form.Label>
211
+            <Form.Control
212
+              type="text"
213
+              placeholder="例如: https://a9k866906pialdug-80.container.x-gpu.com"
214
+              value={lipSyncServerUrl}
215
+              onChange={handleLipSyncServerChange}
216
+            />
217
+            <Form.Text className="text-muted">
218
+              对口型服务器地址,用于API请求和视频下载
219
+            </Form.Text>
220
+          </Form.Group>
221
+          
222
+          <Button 
223
+            variant="outline-primary" 
224
+            onClick={testLipSyncServer}
225
+            disabled={testing || !lipSyncServerUrl}
226
+            className="mb-3"
227
+          >
228
+            {testing ? (
229
+              <>
230
+                <Spinner 
231
+                  as="span"
232
+                  animation="border"
233
+                  size="sm"
234
+                  role="status"
235
+                  aria-hidden="true"
236
+                  className="me-2"
237
+                />
238
+                测试连接中...
239
+              </>
240
+            ) : "测试服务器连接"}
241
+          </Button>
242
+          
243
+          {testResult && (
244
+            <Alert variant={testResult.success ? "success" : "danger"} className="mt-2 mb-3">
245
+              {testResult.message}
246
+            </Alert>
247
+          )}
248
+        </Tab>
156 249
       </Tabs>
157 250
       
158 251
       <div className="text-center mt-4">

+ 30 - 7
src/components/subtitleUpload/index.js

@@ -1,4 +1,4 @@
1
-import React, { useState } from 'react';
1
+import React, { useState, useEffect } from 'react';
2 2
 import { Button, Form, Spinner, Card, ListGroup, Modal, InputGroup, Alert } from 'react-bootstrap';
3 3
 import { toast } from 'react-toastify';
4 4
 import './style.css';
@@ -12,6 +12,7 @@ import { bookService, bookInfoService } from '../../db';
12 12
 import path from 'path-browserify';
13 13
 import os from 'os-browserify/browser';
14 14
 // fs模块在浏览器中不可用,需要通过IPC调用主进程
15
+import cozeUploadService from '../../services/cozeUploadService';
15 16
 
16 17
 const SubtitleUpload = ({ onProjectCreated, projectAudioFile, projectVideoFile }) => {
17 18
   const [file, setFile] = useState(null);
@@ -362,11 +363,22 @@ const SubtitleUpload = ({ onProjectCreated, projectAudioFile, projectVideoFile }
362 363
       const savedAudio = await saveAudioFile(projectAudioFile, bookId);
363 364
       
364 365
       if (savedAudio.success) {
365
-        // 更新项目记录中的音频路径为绝对路径
366
+        // 上传音频到云端获取链接
367
+        toast.info('正在将音频上传到云端...');
368
+        const audioUrl = await cozeUploadService.uploadFileAndGetLink(projectAudioFile);
369
+        
370
+        // 更新项目记录中的音频路径为绝对路径,并保存云端链接
366 371
         await bookService.updateBook(bookId, {
367
-          audio_path: savedAudio.absolutePath
372
+          audio_path: savedAudio.absolutePath,
373
+          audio_url: audioUrl // 新增字段保存云端链接
368 374
         });
369
-        toast.success('音频文件路径已保存');
375
+        
376
+        if (audioUrl) {
377
+          toast.success('音频文件已保存并上传到云端');
378
+        } else {
379
+          toast.success('音频文件路径已保存');
380
+          toast.warning('音频云端链接获取失败,将在生成对口型时重新上传');
381
+        }
370 382
       } else {
371 383
         toast.warning(`保存音频文件失败: ${savedAudio.error}`);
372 384
       }
@@ -379,11 +391,22 @@ const SubtitleUpload = ({ onProjectCreated, projectAudioFile, projectVideoFile }
379 391
       const savedVideo = await saveVideoFile(projectVideoFile, bookId);
380 392
       
381 393
       if (savedVideo.success) {
382
-        // 更新项目记录中的视频路径为绝对路径
394
+        // 上传视频到云端获取链接
395
+        toast.info('正在将视频上传到云端...');
396
+        const videoUrl = await cozeUploadService.uploadFileAndGetLink(projectVideoFile);
397
+        
398
+        // 更新项目记录中的视频路径为绝对路径,并保存云端链接
383 399
         await bookService.updateBook(bookId, {
384
-          video_path: savedVideo.absolutePath
400
+          video_path: savedVideo.absolutePath,
401
+          video_url: videoUrl // 新增字段保存云端链接
385 402
         });
386
-        toast.success('视频文件路径已保存');
403
+        
404
+        if (videoUrl) {
405
+          toast.success('视频文件已保存并上传到云端');
406
+        } else {
407
+          toast.success('视频文件路径已保存');
408
+          toast.warning('视频云端链接获取失败,将在生成对口型时重新上传');
409
+        }
387 410
       } else {
388 411
         toast.warning(`保存视频文件失败: ${savedVideo.error}`);
389 412
       }

+ 39 - 4
src/db/bookService.js

@@ -70,10 +70,45 @@ class BookService {
70 70
         throw new Error(`项目(ID: ${id})不存在`);
71 71
       }
72 72
       
73
-      const updatedBook = {
74
-        ...bookData,
75
-        updated_at: new Date().toISOString()
76
-      };
73
+      const updatedBook = {};
74
+      
75
+      if (bookData.title !== undefined) {
76
+        updatedBook.title = bookData.title;
77
+      }
78
+      
79
+      if (bookData.subtitle_path !== undefined) {
80
+        updatedBook.subtitle_path = bookData.subtitle_path;
81
+      }
82
+      
83
+      if (bookData.audio_path !== undefined) {
84
+        updatedBook.audio_path = bookData.audio_path;
85
+      }
86
+      
87
+      if (bookData.video_path !== undefined) {
88
+        updatedBook.video_path = bookData.video_path;
89
+      }
90
+      
91
+      if (bookData.lip_sync_video_path !== undefined) {
92
+        updatedBook.lip_sync_video_path = bookData.lip_sync_video_path;
93
+      }
94
+      
95
+      if (bookData.lip_sync_task_id !== undefined) {
96
+        updatedBook.lip_sync_task_id = bookData.lip_sync_task_id;
97
+      }
98
+      
99
+      if (bookData.audio_url !== undefined) {
100
+        updatedBook.audio_url = bookData.audio_url;
101
+      }
102
+      
103
+      if (bookData.video_url !== undefined) {
104
+        updatedBook.video_url = bookData.video_url;
105
+      }
106
+      
107
+      if (bookData.style !== undefined) {
108
+        updatedBook.style = bookData.style;
109
+      }
110
+      
111
+      updatedBook.updated_at = new Date().toISOString();
77 112
       
78 113
       await db('book').where({ id }).update(updatedBook);
79 114
       return true;

+ 39 - 0
src/nodeapi/dbHandler.js

@@ -51,6 +51,42 @@ const updateDbStructure = async (db) => {
51 51
     });
52 52
   }
53 53
 
54
+  // 检查book表是否有lip_sync_video_path字段
55
+  const hasLipSyncVideoPathColumn = await db.schema.hasColumn('book', 'lip_sync_video_path');
56
+  if (!hasLipSyncVideoPathColumn) {
57
+    console.log('添加lip_sync_video_path字段到book表');
58
+    await db.schema.table('book', table => {
59
+      table.string('lip_sync_video_path').nullable();
60
+    });
61
+  }
62
+
63
+  // 检查book表是否有lip_sync_task_id字段
64
+  const hasLipSyncTaskIdColumn = await db.schema.hasColumn('book', 'lip_sync_task_id');
65
+  if (!hasLipSyncTaskIdColumn) {
66
+    console.log('添加lip_sync_task_id字段到book表');
67
+    await db.schema.table('book', table => {
68
+      table.string('lip_sync_task_id').nullable();
69
+    });
70
+  }
71
+
72
+  // 检查book表是否有audio_url字段
73
+  const hasAudioUrlColumn = await db.schema.hasColumn('book', 'audio_url');
74
+  if (!hasAudioUrlColumn) {
75
+    console.log('添加audio_url字段到book表');
76
+    await db.schema.table('book', table => {
77
+      table.string('audio_url').nullable();
78
+    });
79
+  }
80
+
81
+  // 检查book表是否有video_url字段
82
+  const hasVideoUrlColumn = await db.schema.hasColumn('book', 'video_url');
83
+  if (!hasVideoUrlColumn) {
84
+    console.log('添加video_url字段到book表');
85
+    await db.schema.table('book', table => {
86
+      table.string('video_url').nullable();
87
+    });
88
+  }
89
+
54 90
   // 检查book_info表是否有image_path字段
55 91
   const hasImagePathColumn = await db.schema.hasColumn('book_info', 'image_path');
56 92
   if (!hasImagePathColumn) {
@@ -121,6 +157,9 @@ const initDatabase = async (forceUpdate = false) => {
121 157
         table.string('video_path').nullable();
122 158
         table.string('subtitle_path').nullable();
123 159
         table.string('style').nullable();
160
+        table.string('audio_path').nullable();
161
+        table.string('lip_sync_video_path').nullable();
162
+        table.string('lip_sync_task_id').nullable();
124 163
         table.timestamp('created_at').defaultTo(db.fn.now());
125 164
         table.timestamp('updated_at').defaultTo(db.fn.now());
126 165
       });

+ 62 - 14
src/pages/home/home.js

@@ -22,6 +22,8 @@ const Home = forwardRef((props, ref) => {
22 22
   const [currentAudio, setCurrentAudio] = useState(null);
23 23
   const [projectAudioFile, setProjectAudioFile] = useState(null);
24 24
   const [projectVideoFile, setProjectVideoFile] = useState(null);
25
+  const [showVideoPlayer, setShowVideoPlayer] = useState(false);
26
+  const [currentVideo, setCurrentVideo] = useState(null);
25 27
   const history = useHistory();
26 28
 
27 29
   const handleAddProject = () => {
@@ -307,6 +309,21 @@ const Home = forwardRef((props, ref) => {
307 309
     setCurrentAudio(null);
308 310
   };
309 311
 
312
+  // 播放视频
313
+  const playVideo = (projectId, videoPath) => {
314
+    setCurrentVideo({
315
+      projectId,
316
+      path: videoPath
317
+    });
318
+    setShowVideoPlayer(true);
319
+  };
320
+
321
+  // 关闭视频播放器
322
+  const closeVideoPlayer = () => {
323
+    setShowVideoPlayer(false);
324
+    setCurrentVideo(null);
325
+  };
326
+
310 327
   // 组件挂载时加载项目列表
311 328
   useEffect(() => {
312 329
     loadProjects();
@@ -380,6 +397,7 @@ const Home = forwardRef((props, ref) => {
380 397
                             <th>字幕文件</th>
381 398
                             <th>音频文件</th>
382 399
                             <th>视频文件</th>
400
+                            <th>对口型视频</th>
383 401
                             <th>操作</th>
384 402
                           </tr>
385 403
                         </thead>
@@ -397,30 +415,25 @@ const Home = forwardRef((props, ref) => {
397 415
                               </td>
398 416
                               <td>
399 417
                                 {project.audio_path ? (
400
-                                  <div className="file-path-badge" title={project.audio_path}>
401
-                                    <Badge bg="success">
402
-                                      {project.audio_path.length > 15 
403
-                                        ? project.audio_path.substring(0, 6) + '...' + project.audio_path.substring(project.audio_path.length - 6) 
404
-                                        : project.audio_path}
405
-                                    </Badge>
406
-                                  </div>
418
+                                  <Badge bg="success">已上传</Badge>
407 419
                                 ) : (
408 420
                                   <span className="text-muted">无</span>
409 421
                                 )}
410 422
                               </td>
411 423
                               <td>
412 424
                                 {project.video_path ? (
413
-                                  <div className="file-path-badge" title={project.video_path}>
414
-                                    <Badge bg="primary">
415
-                                      {project.video_path.length > 15 
416
-                                        ? project.video_path.substring(0, 6) + '...' + project.video_path.substring(project.video_path.length - 6) 
417
-                                        : project.video_path}
418
-                                    </Badge>
419
-                                  </div>
425
+                                  <Badge bg="success">已上传</Badge>
420 426
                                 ) : (
421 427
                                   <span className="text-muted">无</span>
422 428
                                 )}
423 429
                               </td>
430
+                              <td>
431
+                                {project.lip_sync_video_path ? (
432
+                                  <Badge bg="primary">已生成</Badge>
433
+                                ) : (
434
+                                  <span className="text-muted">未生成</span>
435
+                                )}
436
+                              </td>
424 437
                               <td className="action-buttons">
425 438
                                 <Button
426 439
                                   variant="outline-primary"
@@ -452,6 +465,15 @@ const Home = forwardRef((props, ref) => {
452 465
                                     播放音频
453 466
                                   </Button>
454 467
                                 )}
468
+                                {project.lip_sync_video_path && (
469
+                                  <Button
470
+                                    variant="outline-primary"
471
+                                    size="sm"
472
+                                    onClick={() => playVideo(project.id, project.lip_sync_video_path)}
473
+                                  >
474
+                                    播放对口型
475
+                                  </Button>
476
+                                )}
455 477
                                 <Button
456 478
                                   variant="outline-danger"
457 479
                                   size="sm"
@@ -577,6 +599,32 @@ const Home = forwardRef((props, ref) => {
577 599
           </Button>
578 600
         </Modal.Footer>
579 601
       </Modal>
602
+      
603
+      {/* 视频播放模态框 */}
604
+      <Modal show={showVideoPlayer} onHide={closeVideoPlayer} size="lg" centered>
605
+        <Modal.Header closeButton>
606
+          <Modal.Title>视频播放</Modal.Title>
607
+        </Modal.Header>
608
+        <Modal.Body>
609
+          {currentVideo && (
610
+            <div className="video-player-container">
611
+              <video 
612
+                className="w-100" 
613
+                controls 
614
+                autoPlay
615
+                src={currentVideo.path}
616
+              >
617
+                您的浏览器不支持视频播放
618
+              </video>
619
+            </div>
620
+          )}
621
+        </Modal.Body>
622
+        <Modal.Footer>
623
+          <Button variant="secondary" onClick={closeVideoPlayer}>
624
+            关闭
625
+          </Button>
626
+        </Modal.Footer>
627
+      </Modal>
580 628
     </>
581 629
   );
582 630
 });

+ 229 - 18
src/pages/projectDetail/projectDetail.js

@@ -2,7 +2,7 @@ import React, { useEffect, useState, useRef } from 'react';
2 2
 import { useParams, useHistory } from 'react-router-dom';
3 3
 import {
4 4
   Button, Row, Col, Container, Card, Table, Badge, Form,
5
-  Modal, InputGroup, FormControl, Dropdown, OverlayTrigger, Tooltip, Alert
5
+  Modal, InputGroup, FormControl, Dropdown, OverlayTrigger, Tooltip, Alert, ProgressBar, Spinner
6 6
 } from 'react-bootstrap';
7 7
 import { toast } from 'react-toastify';
8 8
 import { bookService, bookInfoService, initDb } from '../../db';
@@ -15,6 +15,8 @@ import path from 'path-browserify';
15 15
 
16 16
 // 引入AudioPlayer组件
17 17
 import AudioPlayerComponent from '../../components/audioPlayer';
18
+import lipSyncService from '../../services/lipSyncService';
19
+import cozeUploadService from '../../services/cozeUploadService';
18 20
 
19 21
 const ProjectDetail = () => {
20 22
   const { projectId } = useParams();
@@ -53,10 +55,18 @@ const ProjectDetail = () => {
53 55
   // 在状态变量中添加项目音频相关状态
54 56
   const [projectAudioPath, setProjectAudioPath] = useState(null);
55 57
   const [showAudioPlayer, setShowAudioPlayer] = useState(false);
58
+  const [projectVideoPath, setProjectVideoPath] = useState(null);
59
+  const [showVideoPlayer, setShowVideoPlayer] = useState(false);
56 60
   const [showUploadProjectAudioModal, setShowUploadProjectAudioModal] = useState(false);
57 61
   const [projectAudioFile, setProjectAudioFile] = useState(null);
58 62
   const [uploadingProjectAudio, setUploadingProjectAudio] = useState(false);
59 63
 
64
+  // 对口型相关状态
65
+  const [lipSyncProcessing, setLipSyncProcessing] = useState(false);
66
+  const [lipSyncProgress, setLipSyncProgress] = useState(0);
67
+  const [lipSyncTaskId, setLipSyncTaskId] = useState(null);
68
+  const [lipSyncResultUrl, setLipSyncResultUrl] = useState(null);
69
+
60 70
   const fileInputRef = useRef(null);
61 71
 
62 72
   // 辅助函数:根据ID获取单个分镜信息
@@ -92,6 +102,15 @@ const ProjectDetail = () => {
92 102
         setProjectAudioPath(null);
93 103
         setShowAudioPlayer(false);
94 104
       }
105
+      
106
+      // 加载项目视频路径
107
+      if (projectData && projectData.video_path) {
108
+        setProjectVideoPath(projectData.video_path);
109
+        setShowVideoPlayer(true);
110
+      } else {
111
+        setProjectVideoPath(null);
112
+        setShowVideoPlayer(false);
113
+      }
95 114
 
96 115
       // 检查是否已经有描述词
97 116
       const hasAnyDescriptions = Array.isArray(segmentsData) &&
@@ -1847,8 +1866,15 @@ const ProjectDetail = () => {
1847 1866
         throw new Error(result.error || '保存音频文件失败');
1848 1867
       }
1849 1868
 
1850
-      // 更新项目数据库记录
1851
-      await bookService.updateBook(parseInt(projectId), { audio_path: result.filePath });
1869
+      // 将本地文件上传到Coze获取可访问链接
1870
+      toast.info('正在将音频上传到云端...');
1871
+      const audioUrl = await cozeUploadService.uploadFileAndGetLink(projectAudioFile);
1872
+      
1873
+      // 更新项目数据库记录 - 同时保存本地路径和云端链接
1874
+      await bookService.updateBook(parseInt(projectId), { 
1875
+        audio_path: result.filePath,
1876
+        audio_url: audioUrl // 新增字段保存云端链接
1877
+      });
1852 1878
 
1853 1879
       // 更新状态
1854 1880
       setProjectAudioPath(result.filePath);
@@ -1856,7 +1882,12 @@ const ProjectDetail = () => {
1856 1882
       setProjectAudioFile(null);
1857 1883
       setShowUploadProjectAudioModal(false);
1858 1884
 
1859
-      toast.success(`项目音频${projectAudioPath ? '更新' : '上传'}成功`);
1885
+      if (audioUrl) {
1886
+        toast.success('项目音频已成功上传并保存云端链接');
1887
+      } else {
1888
+        toast.success(`项目音频${projectAudioPath ? '更新' : '上传'}成功`);
1889
+        toast.warning('云端链接保存失败,将在生成对口型时重新上传');
1890
+      }
1860 1891
     } catch (error) {
1861 1892
       console.error('上传项目音频失败:', error);
1862 1893
       toast.error(`上传项目音频失败: ${error.message}`);
@@ -1865,6 +1896,114 @@ const ProjectDetail = () => {
1865 1896
     }
1866 1897
   };
1867 1898
 
1899
+  /**
1900
+   * 处理对口型生成
1901
+   */
1902
+  const handleLipSyncGeneration = async () => {
1903
+    try {
1904
+      // 检查是否有音频和视频
1905
+      if (!project.audio_path || !project.video_path) {
1906
+        toast.error('请先上传音频和视频文件');
1907
+        return;
1908
+      }
1909
+      
1910
+      // 确认是否开始处理
1911
+      if (!window.confirm('确定要开始生成对口型视频吗?此过程可能需要几分钟时间。')) {
1912
+        return;
1913
+      }
1914
+      
1915
+      setLipSyncProcessing(true);
1916
+      setLipSyncProgress(0);
1917
+      
1918
+      let audioUrl = project.audio_url; // 尝试使用已保存的云端链接
1919
+      let videoUrl = project.video_url; // 尝试使用已保存的云端链接
1920
+      
1921
+      // 如果没有保存的云端链接,则进行上传
1922
+      if (!audioUrl || !videoUrl) {
1923
+        toast.info('正在上传音频和视频文件...');
1924
+        
1925
+        // 异步上传音频和视频
1926
+        const uploadResults = await Promise.all([
1927
+          !audioUrl ? cozeUploadService.uploadProjectMedia(project.audio_path) : Promise.resolve(audioUrl),
1928
+          !videoUrl ? cozeUploadService.uploadProjectMedia(project.video_path) : Promise.resolve(videoUrl)
1929
+        ]);
1930
+        
1931
+        audioUrl = uploadResults[0];
1932
+        videoUrl = uploadResults[1];
1933
+        
1934
+        // 如果成功获取到链接,保存到数据库中以便下次使用
1935
+        if (audioUrl && audioUrl !== project.audio_url) {
1936
+          try {
1937
+            await bookService.updateBook(project.id, { audio_url: audioUrl });
1938
+            console.log('已保存音频云端链接');
1939
+          } catch (error) {
1940
+            console.error('保存音频云端链接失败:', error);
1941
+          }
1942
+        }
1943
+        
1944
+        if (videoUrl && videoUrl !== project.video_url) {
1945
+          try {
1946
+            await bookService.updateBook(project.id, { video_url: videoUrl });
1947
+            console.log('已保存视频云端链接');
1948
+          } catch (error) {
1949
+            console.error('保存视频云端链接失败:', error);
1950
+          }
1951
+        }
1952
+      } else {
1953
+        toast.info('使用已保存的音频和视频链接...');
1954
+      }
1955
+      
1956
+      if (!audioUrl || !videoUrl) {
1957
+        throw new Error('上传音频或视频文件失败');
1958
+      }
1959
+      
1960
+      toast.success('文件准备完成,开始处理对口型...');
1961
+      
1962
+      // 开始处理对口型任务
1963
+      lipSyncService.processLipSyncTask(
1964
+        videoUrl,
1965
+        audioUrl,
1966
+        // 进度更新回调
1967
+        (progress, status) => {
1968
+          console.log(`对口型进度: ${progress}%, 状态: ${status}`);
1969
+          setLipSyncProgress(progress);
1970
+        },
1971
+        // 完成回调
1972
+        async (resultUrl, taskInfo) => {
1973
+          console.log('对口型处理完成:', resultUrl);
1974
+          setLipSyncResultUrl(resultUrl);
1975
+          setLipSyncProcessing(false);
1976
+          
1977
+          // 保存结果到项目
1978
+          try {
1979
+            await bookService.updateBook(project.id, {
1980
+              lip_sync_video_path: resultUrl,
1981
+              video_path: resultUrl, // 同时更新视频路径
1982
+              lip_sync_task_id: taskInfo.task_id
1983
+            });
1984
+            
1985
+            toast.success('对口型视频已保存到项目');
1986
+            // 重新加载项目详情
1987
+            await loadProjectDetail(true);
1988
+          } catch (error) {
1989
+            console.error('保存对口型结果失败:', error);
1990
+            toast.error(`保存失败: ${error.message}`);
1991
+          }
1992
+        },
1993
+        // 错误回调
1994
+        (error) => {
1995
+          console.error('对口型处理失败:', error);
1996
+          toast.error(`对口型处理失败: ${error.message}`);
1997
+          setLipSyncProcessing(false);
1998
+        }
1999
+      );
2000
+    } catch (error) {
2001
+      console.error('处理对口型失败:', error);
2002
+      toast.error(`处理失败: ${error.message}`);
2003
+      setLipSyncProcessing(false);
2004
+    }
2005
+  };
2006
+
1868 2007
   if (loading && !silentLoading) {
1869 2008
     return <div className="text-center p-5">加载中...</div>;
1870 2009
   }
@@ -1905,7 +2044,7 @@ const ProjectDetail = () => {
1905 2044
               <Card.Body>
1906 2045
                 <Row className="align-items-center">
1907 2046
                   {/* 项目音频 */}
1908
-                  <Col md={4}>
2047
+                  <Col md={3}>
1909 2048
                     <Form.Group>
1910 2049
                       <Form.Label>项目音频</Form.Label>
1911 2050
                       {projectAudioPath && projectAudioPath.trim() !== '' ? (
@@ -1926,9 +2065,9 @@ const ProjectDetail = () => {
1926 2065
                   </Col>
1927 2066
                   
1928 2067
                   {/* 项目视频 */}
1929
-                  <Col md={4}>
2068
+                  <Col md={3}>
1930 2069
                     <Form.Group>
1931
-                      <Form.Label>对口型视频</Form.Label>
2070
+                      <Form.Label>项目视频</Form.Label>
1932 2071
                       {project.video_path && project.video_path.trim() !== '' ? (
1933 2072
                         <div className="project-video-container">
1934 2073
                           <video 
@@ -1942,12 +2081,87 @@ const ProjectDetail = () => {
1942 2081
                         </div>
1943 2082
                       ) : (
1944 2083
                         <Alert variant="warning" className="mb-0">
1945
-                          <small>暂无对口型视频</small>
2084
+                          <small>暂无项目视频</small>
2085
+                        </Alert>
2086
+                      )}
2087
+                    </Form.Group>
2088
+                  </Col>
2089
+                  
2090
+                  {/* 对口型生成区域 */}
2091
+                  <Col md={3}>
2092
+                    <Form.Group>
2093
+                      <Form.Label>对口型视频</Form.Label>
2094
+                      {lipSyncProcessing ? (
2095
+                        <div className="lip-sync-progress">
2096
+                          <ProgressBar 
2097
+                            animated 
2098
+                            now={lipSyncProgress} 
2099
+                            label={`${Math.round(lipSyncProgress)}%`} 
2100
+                            className="mb-2"
2101
+                          />
2102
+                          <div className="text-center">
2103
+                            <small className="text-muted">正在生成对口型视频,请耐心等待...</small>
2104
+                          </div>
2105
+                        </div>
2106
+                      ) : project.lip_sync_video_path ? (
2107
+                        <div className="lip-sync-video-container">
2108
+                          <video 
2109
+                            className="w-100" 
2110
+                            controls 
2111
+                            src={project.lip_sync_video_path}
2112
+                            style={{ maxHeight: '150px' }}
2113
+                          >
2114
+                            您的浏览器不支持视频播放
2115
+                          </video>
2116
+                        </div>
2117
+                      ) : (
2118
+                        <Alert variant="info" className="mb-0">
2119
+                          <small>
2120
+                            暂无对口型视频
2121
+                            {(project.audio_path && project.video_path) ? 
2122
+                              <div className="mt-1">
2123
+                                <small>
2124
+                                  已上传音频和视频文件,可以点击"生成对口型"按钮开始处理
2125
+                                </small>
2126
+                              </div> : 
2127
+                              <div className="mt-1">
2128
+                                <small>
2129
+                                  需要先上传音频和视频文件才能生成对口型
2130
+                                </small>
2131
+                              </div>
2132
+                            }
2133
+                          </small>
1946 2134
                         </Alert>
1947 2135
                       )}
1948 2136
                     </Form.Group>
1949 2137
                   </Col>
1950 2138
                   
2139
+                  {/* 对口型操作按钮 */}
2140
+                  <Col md={3} className="d-flex align-items-end">
2141
+                    <div className="w-100">
2142
+                      <Button
2143
+                        variant="primary"
2144
+                        className="w-100"
2145
+                        disabled={lipSyncProcessing || !project.audio_path || !project.video_path}
2146
+                        onClick={handleLipSyncGeneration}
2147
+                      >
2148
+                        {lipSyncProcessing ? (
2149
+                          <>
2150
+                            <Spinner
2151
+                              as="span"
2152
+                              animation="border"
2153
+                              size="sm"
2154
+                              role="status"
2155
+                              aria-hidden="true"
2156
+                              className="me-1"
2157
+                            />
2158
+                            处理中...
2159
+                          </>
2160
+                        ) : project.lip_sync_video_path ? "重新生成对口型" : "生成对口型"}
2161
+                      </Button>
2162
+                    </div>
2163
+                  </Col>
2164
+                  
1951 2165
                   {/* 项目画风选择 */}
1952 2166
                   <Col md={4}>
1953 2167
                     <Form.Group>
@@ -2300,19 +2514,16 @@ const ProjectDetail = () => {
2300 2514
             </Card>
2301 2515
           </div>
2302 2516
         ) : (
2303
-          <div className="text-center p-5">
2304
-            <p className="text-muted">项目不存在或已被删除</p>
2305
-            <Button
2306
-              variant="primary"
2307
-              onClick={backToProjectList}
2308
-              className="mt-3"
2309
-            >
2310
-              返回项目列表
2311
-            </Button>
2517
+          <div className="text-center my-5">
2518
+            {loading ? (
2519
+              <Spinner animation="border" />
2520
+            ) : (
2521
+              <Alert variant="warning">项目不存在或已被删除</Alert>
2522
+            )}
2312 2523
           </div>
2313 2524
         )}
2314 2525
       </Container>
2315
-
2526
+      
2316 2527
       {/* 合并分镜对话框 */}
2317 2528
       <Modal show={showMergeModal} onHide={() => setShowMergeModal(false)}>
2318 2529
         <Modal.Header closeButton>

+ 295 - 0
src/services/cozeUploadService.js

@@ -0,0 +1,295 @@
1
+import axios from 'axios';
2
+import { toast } from 'react-toastify';
3
+
4
+const COZE_API_TOKEN = 'pat_RiXRbDpm7UTfnXskaEhzRMJ2qEVclaNyX5dmhKhFS7ytX5W0LTK2acAhQx94fajw';
5
+const COZE_WORKFLOW_ID = '7484196116684095488';
6
+
7
+/**
8
+ * 直接上传文件到Coze并获取可访问链接
9
+ * @param {File} file - 要上传的文件对象(音频或视频)
10
+ * @returns {Promise<string>} - 成功时返回可访问的文件链接,失败时返回null
11
+ */
12
+const uploadFileAndGetLink = async (file) => {
13
+  try {
14
+    // 第一步:上传文件
15
+    const fileId = await uploadFileToCoze(file);
16
+    if (!fileId) {
17
+      throw new Error('文件上传失败');
18
+    }
19
+
20
+    // 第二步:通过工作流获取文件链接
21
+    const fileUrl = await getFileLinkFromWorkflow(fileId);
22
+    if (!fileUrl) {
23
+      throw new Error('获取文件链接失败');
24
+    }
25
+
26
+    return fileUrl;
27
+  } catch (error) {
28
+    console.error('文件上传和处理失败:', error);
29
+    toast.error(`文件上传和处理失败: ${error.message}`);
30
+    return null;
31
+  }
32
+};
33
+
34
+/**
35
+ * 上传文件到Coze API
36
+ * @param {File} file - 要上传的文件对象
37
+ * @returns {Promise<string>} - 成功时返回文件ID,失败时返回null
38
+ */
39
+const uploadFileToCoze = async (file) => {
40
+  try {
41
+    const formData = new FormData();
42
+    formData.append('file', file);
43
+
44
+    const response = await axios.post('https://api.coze.cn/v1/files/upload', formData, {
45
+      headers: {
46
+        'Authorization': `Bearer ${COZE_API_TOKEN}`,
47
+        'Content-Type': 'multipart/form-data'
48
+      }
49
+    });
50
+
51
+    if (response.data.code === 0 && response.data.data && response.data.data.id) {
52
+      console.log('文件上传成功,ID:', response.data.data.id);
53
+      return response.data.data.id;
54
+    } else {
55
+      console.error('文件上传失败,返回数据:', response.data);
56
+      throw new Error(response.data.msg || '上传失败');
57
+    }
58
+  } catch (error) {
59
+    console.error('文件上传错误:', error);
60
+    if (error.response) {
61
+      console.error('API响应:', error.response.data);
62
+    }
63
+    throw new Error(`上传文件错误: ${error.message}`);
64
+  }
65
+};
66
+
67
+/**
68
+ * 通过Coze工作流获取文件的可访问链接
69
+ * @param {string} fileId - 文件ID
70
+ * @returns {Promise<string>} - 成功时返回文件链接,失败时返回null
71
+ */
72
+const getFileLinkFromWorkflow = async (fileId) => {
73
+  try {
74
+    const payload = {
75
+      parameters: {
76
+        input: JSON.stringify({ file_id: fileId })
77
+      },
78
+      workflow_id: COZE_WORKFLOW_ID
79
+    };
80
+
81
+    const response = await axios.post('https://api.coze.cn/v1/workflow/run', payload, {
82
+      headers: {
83
+        'Authorization': `Bearer ${COZE_API_TOKEN}`,
84
+        'Content-Type': 'application/json'
85
+      }
86
+    });
87
+
88
+    if (response.data.code === 0 && response.data.data) {
89
+      try {
90
+        const outputData = JSON.parse(response.data.data);
91
+        if (outputData && outputData.output) {
92
+          console.log('获取文件链接成功:', outputData.output);
93
+          return outputData.output;
94
+        }
95
+      } catch (parseError) {
96
+        console.error('解析返回数据失败:', parseError);
97
+      }
98
+    }
99
+    
100
+    console.error('工作流调用失败,返回数据:', response.data);
101
+    throw new Error(response.data.msg || '获取链接失败');
102
+  } catch (error) {
103
+    console.error('调用工作流错误:', error);
104
+    if (error.response) {
105
+      console.error('API响应:', error.response.data);
106
+    }
107
+    throw new Error(`获取文件链接错误: ${error.message}`);
108
+  }
109
+};
110
+
111
+/**
112
+ * 从本地路径上传文件到Coze
113
+ * 提示用户直接选择音频或视频文件上传
114
+ * 
115
+ * @returns {Promise<string>} - 成功时返回可访问链接,失败时返回null
116
+ */
117
+const uploadFileByDialog = async () => {
118
+  try {
119
+    // 创建一个input元素来选择文件
120
+    const input = document.createElement('input');
121
+    input.type = 'file';
122
+    input.style.display = 'none';
123
+    input.accept = 'audio/*,video/*'; // 接受音频和视频
124
+    document.body.appendChild(input);
125
+    
126
+    // 返回一个Promise,在用户选择文件后解析
127
+    return new Promise((resolve) => {
128
+      input.onchange = async (event) => {
129
+        if (event.target.files && event.target.files.length > 0) {
130
+          const file = event.target.files[0];
131
+          const url = await uploadFileAndGetLink(file);
132
+          document.body.removeChild(input);
133
+          resolve(url);
134
+        } else {
135
+          document.body.removeChild(input);
136
+          resolve(null);
137
+        }
138
+      };
139
+      
140
+      // 点击文件选择器
141
+      input.click();
142
+    });
143
+
144
+  } catch (error) {
145
+    console.error('文件选择上传失败:', error);
146
+    toast.error(`上传失败: ${error.message}`);
147
+    return null;
148
+  }
149
+};
150
+
151
+/**
152
+ * 从URL获取文件并上传到Coze
153
+ * 
154
+ * @param {string} url - 文件URL地址
155
+ * @param {string} fileName - 文件名称(可选)
156
+ * @returns {Promise<string>} - 成功时返回可访问链接,失败时返回null
157
+ */
158
+const uploadFromUrl = async (url, fileName = null) => {
159
+  try {
160
+    // 获取文件内容
161
+    const response = await fetch(url);
162
+    if (!response.ok) {
163
+      throw new Error(`获取文件失败: ${response.statusText}`);
164
+    }
165
+    
166
+    // 获取文件类型和文件名
167
+    const contentType = response.headers.get('content-type') || '';
168
+    const blob = await response.blob();
169
+    
170
+    // 如果没有提供文件名,尝试从URL中提取
171
+    if (!fileName) {
172
+      const urlParts = url.split('/');
173
+      fileName = urlParts[urlParts.length - 1].split('?')[0] || 'file';
174
+    }
175
+    
176
+    // 创建文件对象并上传
177
+    const file = new File([blob], fileName, { type: contentType });
178
+    return await uploadFileAndGetLink(file);
179
+    
180
+  } catch (error) {
181
+    console.error('从URL上传文件失败:', error);
182
+    toast.error(`从URL上传失败: ${error.message}`);
183
+    return null;
184
+  }
185
+};
186
+
187
+/**
188
+ * 使用Electron的文件对话框选择文件并上传
189
+ * 
190
+ * @returns {Promise<string>} - 成功时返回可访问链接,失败时返回null
191
+ */
192
+const uploadUsingElectronDialog = async () => {
193
+  try {
194
+    // 检查是否在Electron环境中
195
+    if (typeof window.electron === 'undefined' || !window.electron.ipcRenderer) {
196
+      throw new Error('此方法仅在Electron环境中可用');
197
+    }
198
+    
199
+    // 打开文件选择对话框
200
+    const result = await window.electron.ipcRenderer.invoke('open-file-dialog', {
201
+      filters: [
202
+        { name: '媒体文件', extensions: ['mp3', 'wav', 'ogg', 'mp4', 'avi', 'mov'] }
203
+      ],
204
+      properties: ['openFile']
205
+    });
206
+    
207
+    if (result.canceled || !result.filePaths || result.filePaths.length === 0) {
208
+      console.log('用户取消了选择');
209
+      return null;
210
+    }
211
+    
212
+    // 获取选择的文件路径
213
+    const filePath = result.filePaths[0];
214
+    console.log('选择的文件路径:', filePath);
215
+    
216
+    // 让用户再次选择文件通过浏览器上传
217
+    // 因为我们不能直接从文件系统路径创建File对象
218
+    toast.info('请在弹出的对话框中再次选择相同的文件进行上传');
219
+    
220
+    return await uploadFileByDialog();
221
+    
222
+  } catch (error) {
223
+    console.error('使用Electron对话框选择文件失败:', error);
224
+    toast.error(`选择文件失败: ${error.message}`);
225
+    return null;
226
+  }
227
+};
228
+
229
+/**
230
+ * 使用项目中保存的绝对路径上传文件
231
+ * 直接将绝对路径转为文件对象并上传,不需要用户交互
232
+ * 
233
+ * @param {string} filePath - 文件的绝对路径
234
+ * @returns {Promise<string>} - 成功时返回可访问链接,失败时返回null
235
+ */
236
+const uploadProjectMedia = async (filePath) => {
237
+  try {
238
+    // 检查路径是否存在
239
+    if (!filePath || typeof filePath !== 'string') {
240
+      throw new Error('无效的文件路径');
241
+    }
242
+
243
+    // 优先检查是否为http/https链接,如果是则直接使用
244
+    if (filePath.startsWith('http://') || filePath.startsWith('https://')) {
245
+      return filePath; // 已经是URL,直接返回
246
+    }
247
+
248
+    // 如果是file://协议,则转换为本地路径
249
+    let localPath = filePath;
250
+    if (filePath.startsWith('file://')) {
251
+      localPath = filePath.replace('file://', '');
252
+    }
253
+
254
+    // 检查Electron环境
255
+    if (typeof window.electron === 'undefined' || !window.electron.ipcRenderer) {
256
+      // 如果不在Electron环境,无法读取本地文件
257
+      toast.error('当前环境不支持直接读取本地文件');
258
+      return null;
259
+    }
260
+
261
+    try {
262
+      // 直接读取文件内容
263
+      const fileBuffer = await window.electron.files.readFileAsArrayBuffer(localPath);
264
+      if (!fileBuffer) {
265
+        throw new Error('读取文件内容失败');
266
+      }
267
+      
268
+      // 从路径中提取文件名
269
+      const fileName = localPath.split('/').pop() || 'file';
270
+      
271
+      // 创建文件对象
272
+      const file = new File([fileBuffer], fileName);
273
+      
274
+      // 上传文件并获取链接
275
+      return await uploadFileAndGetLink(file);
276
+    } catch (error) {
277
+      console.error('读取和上传文件失败:', error);
278
+      throw new Error(`读取和上传文件失败: ${error.message}`);
279
+    }
280
+  } catch (error) {
281
+    console.error('项目媒体文件上传失败:', error);
282
+    toast.error(`上传失败: ${error.message}`);
283
+    return null;
284
+  }
285
+};
286
+
287
+export default {
288
+  uploadFileAndGetLink,
289
+  uploadFileByDialog,
290
+  uploadFromUrl,
291
+  uploadUsingElectronDialog,
292
+  uploadProjectMedia,
293
+  uploadFileToCoze,
294
+  getFileLinkFromWorkflow
295
+}; 

+ 197 - 0
src/services/lipSyncService.js

@@ -0,0 +1,197 @@
1
+import axios from 'axios';
2
+import { toast } from 'react-toastify';
3
+import { getLipSyncServer } from '../utils/lipSyncConfig';
4
+
5
+// 从配置中获取服务器地址
6
+const getServerUrl = () => {
7
+  return getLipSyncServer();
8
+};
9
+
10
+/**
11
+ * 测试对口型服务器连接
12
+ * @param {string} serverUrl - 服务器地址
13
+ * @returns {Promise<{success: boolean, message: string}>} - 测试结果
14
+ */
15
+const testServer = async (serverUrl) => {
16
+  try {
17
+    // 测试服务器
18
+    const response = await axios.get(`${serverUrl}/direct-lip-sync/`, {
19
+      headers: {
20
+        'accept': 'application/json, text/plain, */*',
21
+        'origin': 'https://heygem.fyshark.com',
22
+        'referer': 'https://heygem.fyshark.com/'
23
+      },
24
+      timeout: 5000 // 5秒超时
25
+    });
26
+    
27
+    if (!response.data) {
28
+      return { success: false, message: '服务器响应无效' };
29
+    }
30
+    
31
+    return { success: true, message: '对口型服务器连接正常' };
32
+  } catch (error) {
33
+    console.error('测试对口型服务器失败:', error);
34
+    return { 
35
+      success: false, 
36
+      message: `服务器连接失败: ${error.message}` 
37
+    };
38
+  }
39
+};
40
+
41
+/**
42
+ * 提交对口型合成任务
43
+ * @param {string} videoUrl - 视频URL
44
+ * @param {string} audioUrl - 音频URL
45
+ * @returns {Promise<string>} - 返回任务ID
46
+ */
47
+const submitLipSyncTask = async (videoUrl, audioUrl) => {
48
+  try {
49
+    if (!videoUrl || !audioUrl) {
50
+      throw new Error('视频URL和音频URL不能为空');
51
+    }
52
+
53
+    const serverUrl = getServerUrl();
54
+    const response = await axios.post(`${serverUrl}/direct-lip-sync/`, {
55
+      video_url: videoUrl,
56
+      audio_url: audioUrl
57
+    }, {
58
+      headers: {
59
+        'accept': 'application/json, text/plain, */*',
60
+        'content-type': 'application/json',
61
+        'origin': 'https://heygem.fyshark.com',
62
+        'referer': 'https://heygem.fyshark.com/'
63
+      }
64
+    });
65
+
66
+    if (response.data && response.data.length > 0 && response.data[0].task_id) {
67
+      console.log('对口型任务提交成功:', response.data[0]);
68
+      return response.data[0].task_id;
69
+    } else {
70
+      console.error('对口型任务提交失败:', response.data);
71
+      throw new Error('提交任务失败,未返回任务ID');
72
+    }
73
+  } catch (error) {
74
+    console.error('提交对口型任务错误:', error);
75
+    toast.error(`提交对口型任务失败: ${error.message}`);
76
+    throw error;
77
+  }
78
+};
79
+
80
+/**
81
+ * 获取对口型任务信息
82
+ * @param {string} taskId - 任务ID
83
+ * @returns {Promise<Object>} - 返回任务信息
84
+ */
85
+const getLipSyncTaskInfo = async (taskId) => {
86
+  try {
87
+    if (!taskId) {
88
+      throw new Error('任务ID不能为空');
89
+    }
90
+
91
+    const serverUrl = getServerUrl();
92
+    const response = await axios.get(`${serverUrl}/direct-lip-sync/?task_id=${taskId}`, {
93
+      headers: {
94
+        'accept': 'application/json, text/plain, */*',
95
+        'origin': 'https://heygem.fyshark.com',
96
+        'referer': 'https://heygem.fyshark.com/'
97
+      }
98
+    });
99
+
100
+    if (response.data && response.data.length > 0) {
101
+      console.log('对口型任务信息:', response.data[0]);
102
+      return response.data[0];
103
+    } else {
104
+      console.error('获取任务信息失败:', response.data);
105
+      throw new Error('获取任务信息失败,未返回有效数据');
106
+    }
107
+  } catch (error) {
108
+    console.error('获取对口型任务信息错误:', error);
109
+    toast.error(`获取任务状态失败: ${error.message}`);
110
+    throw error;
111
+  }
112
+};
113
+
114
+/**
115
+ * 获取对口型结果视频URL
116
+ * @param {string} resultPath - 结果路径
117
+ * @returns {string} - 完整的结果视频URL
118
+ */
119
+const getLipSyncResultUrl = (resultPath) => {
120
+  if (!resultPath) return null;
121
+  
122
+  const serverUrl = getServerUrl();
123
+  
124
+  // 确保路径以/开头
125
+  const formattedPath = resultPath.startsWith('/') ? resultPath : `/${resultPath}`;
126
+  return `${serverUrl}/download${formattedPath}`;
127
+};
128
+
129
+/**
130
+ * 提交对口型任务并定期检查进度直到完成
131
+ * @param {string} videoUrl - 视频URL
132
+ * @param {string} audioUrl - 音频URL
133
+ * @param {function} onProgressUpdate - 进度更新回调函数 (progress, status) => void
134
+ * @param {function} onComplete - 完成回调函数 (resultUrl) => void
135
+ * @param {function} onError - 错误回调函数 (error) => void
136
+ * @returns {Promise<void>}
137
+ */
138
+const processLipSyncTask = async (videoUrl, audioUrl, onProgressUpdate, onComplete, onError) => {
139
+  try {
140
+    // 提交任务
141
+    const taskId = await submitLipSyncTask(videoUrl, audioUrl);
142
+    
143
+    // 定义检查间隔(秒)
144
+    const checkInterval = 5;
145
+    let completed = false;
146
+    
147
+    // 轮询检查任务状态
148
+    const checkStatus = async () => {
149
+      try {
150
+        if (completed) return;
151
+        
152
+        const taskInfo = await getLipSyncTaskInfo(taskId);
153
+        
154
+        // 更新进度
155
+        if (onProgressUpdate) {
156
+          onProgressUpdate(taskInfo.progress, taskInfo.status);
157
+        }
158
+        
159
+        // 检查任务状态
160
+        if (taskInfo.status === 2) { // 已完成
161
+          completed = true;
162
+          const resultUrl = getLipSyncResultUrl(taskInfo.result_url);
163
+          if (onComplete) {
164
+            onComplete(resultUrl, taskInfo);
165
+          }
166
+        } else if (taskInfo.status === 1) { // 进行中
167
+          // 继续轮询
168
+          setTimeout(checkStatus, checkInterval * 1000);
169
+        } else { // 错误状态
170
+          completed = true;
171
+          throw new Error(`任务状态异常: ${taskInfo.status}`);
172
+        }
173
+      } catch (error) {
174
+        if (onError) {
175
+          onError(error);
176
+        }
177
+      }
178
+    };
179
+    
180
+    // 开始轮询
181
+    checkStatus();
182
+    
183
+  } catch (error) {
184
+    console.error('处理对口型任务出错:', error);
185
+    if (onError) {
186
+      onError(error);
187
+    }
188
+  }
189
+};
190
+
191
+export default {
192
+  submitLipSyncTask,
193
+  getLipSyncTaskInfo,
194
+  getLipSyncResultUrl,
195
+  processLipSyncTask,
196
+  testServer
197
+}; 

+ 47 - 0
src/utils/lipSyncConfig.js

@@ -0,0 +1,47 @@
1
+/**
2
+ * 对口型服务器配置存储键名
3
+ */
4
+const LIP_SYNC_SERVER_KEY = 'lip_sync_server';
5
+
6
+// 默认服务器配置
7
+const DEFAULT_SERVER_URL = 'https://a9k866906pialdug-80.container.x-gpu.com';
8
+
9
+/**
10
+ * 获取对口型服务器配置
11
+ * @returns {string} 服务器地址
12
+ */
13
+export const getLipSyncServer = () => {
14
+  try {
15
+    const serverUrl = localStorage.getItem(LIP_SYNC_SERVER_KEY);
16
+    if (!serverUrl) {
17
+      return DEFAULT_SERVER_URL;
18
+    }
19
+    return serverUrl;
20
+  } catch (error) {
21
+    console.error('获取对口型服务器配置失败:', error);
22
+    return DEFAULT_SERVER_URL;
23
+  }
24
+};
25
+
26
+/**
27
+ * 保存对口型服务器配置
28
+ * @param {string} serverUrl 服务器地址
29
+ * @returns {boolean} 是否保存成功
30
+ */
31
+export const saveLipSyncServer = (serverUrl) => {
32
+  try {
33
+    localStorage.setItem(LIP_SYNC_SERVER_KEY, serverUrl);
34
+    return true;
35
+  } catch (error) {
36
+    console.error('保存对口型服务器配置失败:', error);
37
+    return false;
38
+  }
39
+};
40
+
41
+/**
42
+ * 重置对口型服务器配置为默认值
43
+ */
44
+export const resetLipSyncServer = () => {
45
+  saveLipSyncServer(DEFAULT_SERVER_URL);
46
+  return DEFAULT_SERVER_URL;
47
+};