diff --git a/apps/api/app/api/routes/tts.py b/apps/api/app/api/routes/tts.py index f1e06dc0f..ec000a5b8 100755 --- a/apps/api/app/api/routes/tts.py +++ b/apps/api/app/api/routes/tts.py @@ -2,7 +2,9 @@ from __future__ import annotations +import json import logging +import subprocess import tempfile from pathlib import Path from typing import Any, Optional @@ -430,6 +432,8 @@ def save_tts_job_to_library( storage_key = f"uploads/voice/tts/{job.id}.{audio_format}" tmp_path: Path | None = None + audio_duration: float | None = None + file_size = 0 try: with tempfile.NamedTemporaryFile(suffix=f".{audio_format}", delete=False) as tmp: tmp_path = Path(tmp.name) @@ -445,6 +449,23 @@ def save_tts_job_to_library( ) file_size = tmp_path.stat().st_size storage_service.upload_file(tmp_path, storage_key, content_type=content_type) + + # 从音频文件提取时长(ffprobe),作为 job.duration 的兜底 + try: + proc = subprocess.run( + [ + "ffprobe", "-v", "quiet", "-print_format", "json", + "-show_format", str(tmp_path), + ], + capture_output=True, text=True, timeout=10, + ) + if proc.returncode == 0: + fmt = json.loads(proc.stdout).get("format", {}) + dur = float(fmt.get("duration", 0)) + if dur > 0: + audio_duration = dur + except Exception: + logger.warning("ffprobe 提取时长失败: job_id=%s", job.id, exc_info=True) except HTTPException: raise except Exception as e: @@ -482,7 +503,7 @@ def save_tts_job_to_library( mime_type=content_type, metadata=metadata_, file_size=file_size, - duration=job.duration or None, + duration=job.duration or audio_duration or None, status=AssetStatus.READY, classification_status=ClassificationStatus.PENDING, # 音频不参与内容分类,保持 pending 与 ingest 链路一致 uploaded_by_user_id=user_id, diff --git a/apps/web/src/api/tts/index.ts b/apps/web/src/api/tts/index.ts index 4cadefa41..0c006f6d0 100644 --- a/apps/web/src/api/tts/index.ts +++ b/apps/web/src/api/tts/index.ts @@ -28,4 +28,5 @@ export { deleteTTSJob, getTtsVoices, previewTts, + extractVideoVoice, } from "./jobs" diff --git a/apps/web/src/api/tts/jobs.ts b/apps/web/src/api/tts/jobs.ts index 69a5860ce..9a7468994 100644 --- a/apps/web/src/api/tts/jobs.ts +++ b/apps/web/src/api/tts/jobs.ts @@ -70,3 +70,56 @@ export const previewTts = async (data: TTSPreviewRequest): Promise("/tts/preview", data) return response.data } + +/** + * 从视频中提取配音(上传视频 → 后端提取人声 → 保存到配音素材库) + * 支持 mp4/mov/webm 格式 + */ +export const extractVideoVoice = async ( + file: File, + onProgress?: (percent: number) => void, +): Promise<{ asset_id: string; duration: number }> => { + const formData = new FormData() + formData.append("file", file) + + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest() + xhr.open("POST", "/api/v1/tts/extract-video-voice") + + // 携带认证 token(从 localStorage 获取,与 apiClient 拦截器一致) + const token = localStorage.getItem("access_token") + if (token) { + xhr.setRequestHeader("Authorization", `Bearer ${token}`) + } + + xhr.timeout = 10 * 60 * 1000 // 10 分钟超时 + + xhr.upload.onprogress = (e) => { + if (e.lengthComputable && onProgress) { + onProgress(Math.round((e.loaded / e.total) * 100)) + } + } + + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + try { + resolve(JSON.parse(xhr.responseText)) + } catch { + reject(new Error("服务器返回数据解析失败")) + } + } else { + try { + const err = JSON.parse(xhr.responseText) + reject(new Error(err.detail || err.message || `提取失败: HTTP ${xhr.status}`)) + } catch { + reject(new Error(`提取失败: HTTP ${xhr.status}`)) + } + } + } + + xhr.onerror = () => reject(new Error("网络错误,请检查网络连接")) + xhr.ontimeout = () => reject(new Error("上传超时(10分钟),请检查网络或尝试更小的文件")) + + xhr.send(formData) + }) +} diff --git a/apps/web/src/pages/voices/VoiceLibrary.tsx b/apps/web/src/pages/voices/VoiceLibrary.tsx index 9ea4d259a..271fe6d06 100755 --- a/apps/web/src/pages/voices/VoiceLibrary.tsx +++ b/apps/web/src/pages/voices/VoiceLibrary.tsx @@ -16,7 +16,12 @@ */ import React, { useCallback, useEffect, useState } from "react" import { useSearchParams } from "react-router-dom" -import { UploadOutlined, AudioOutlined, RobotOutlined } from "@ant-design/icons" +import { + UploadOutlined, + AudioOutlined, + RobotOutlined, + VideoCameraOutlined, +} from "@ant-design/icons" import { Button } from "@/components/ui" import PageHead from "@/components/layout/PageHead" import { type AssetItem } from "@/api/assets" @@ -35,6 +40,8 @@ import { useTtsSynthesize } from "./hooks/useTtsSynthesize" import { useVoiceUpload } from "./hooks/useVoiceUpload" import { useMaterialDelete } from "./hooks/useMaterialDelete" import { useMaterialBatchDelete } from "./hooks/useMaterialBatchDelete" +import { useVideoExtract } from "./hooks/useVideoExtract" +import VideoExtractModal from "./components/VideoExtractModal" import "./voices.css" let toastIdSeq = 0 @@ -159,6 +166,18 @@ const VoiceLibrary: React.FC = () => { handleUploadClose, } = useVoiceUpload({ showToast }) + // ── 提取视频配音 ────────────────────────────────────── + const { + extractOpen, + extractFile, + extractProgress, + isExtracting, + setExtractOpen, + handleFileSelect: handleExtractFileSelect, + handleExtract, + handleExtractClose, + } = useVideoExtract({ showToast }) + // ── URL 参数自动打开上传弹窗 ──────────────────────────── const [searchParams, setSearchParams] = useSearchParams() @@ -200,6 +219,14 @@ const VoiceLibrary: React.FC = () => { > 上传音频 + + )} + + + {progress !== null && ( +
+
+
+
+
+ {progress}% +
+
+ )} + + {isExtracting && ( +

+ {progress === 100 ? "正在提取人声,请稍候..." : "正在上传视频..."} +

+ )} +
+ )} + +
+ + +
+ + ) +} + +export default VideoExtractModal diff --git a/apps/web/src/pages/voices/hooks/useVideoExtract.ts b/apps/web/src/pages/voices/hooks/useVideoExtract.ts new file mode 100644 index 000000000..7a45c56cf --- /dev/null +++ b/apps/web/src/pages/voices/hooks/useVideoExtract.ts @@ -0,0 +1,74 @@ +import { useState, useCallback } from "react" +import { useQueryClient } from "@tanstack/react-query" +import { extractVideoVoice } from "@/api/tts" + +/** + * 视频提取配音 Hook + * 封装视频上传弹窗状态、提取进度、提取 mutation 逻辑 + */ +interface UseVideoExtractProps { + showToast: (message: string, type: "success" | "error") => void +} + +export function useVideoExtract({ showToast }: UseVideoExtractProps) { + const queryClient = useQueryClient() + + const [extractOpen, setExtractOpen] = useState(false) + const [extractFile, setExtractFile] = useState(null) + const [extractProgress, setExtractProgress] = useState(null) + const [isExtracting, setIsExtracting] = useState(false) + + const handleExtractClose = useCallback(() => { + setExtractOpen(false) + setExtractFile(null) + setExtractProgress(null) + setIsExtracting(false) + }, []) + + const handleExtract = useCallback(async () => { + if (!extractFile) return + setIsExtracting(true) + setExtractProgress(0) + try { + await extractVideoVoice(extractFile, (p) => setExtractProgress(p)) + // 刷新素材列表 + queryClient.invalidateQueries({ queryKey: ["assets", "voice"] }) + queryClient.invalidateQueries({ queryKey: ["voice-materials"] }) + showToast("视频配音提取成功", "success") + handleExtractClose() + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : "提取失败,请重试" + showToast(msg, "error") + } finally { + setIsExtracting(false) + setExtractProgress(null) + } + }, [extractFile, queryClient, showToast, handleExtractClose]) + + const handleFileSelect = useCallback( + (file: File | null) => { + if (!file) { + setExtractFile(null) + return + } + const validTypes = ["video/mp4", "video/quicktime", "video/webm"] + if (!validTypes.includes(file.type)) { + showToast("仅支持 MP4、MOV、WebM 格式的视频文件", "error") + return + } + setExtractFile(file) + }, + [showToast], + ) + + return { + extractOpen, + setExtractOpen, + extractFile, + extractProgress, + isExtracting, + handleFileSelect, + handleExtract, + handleExtractClose, + } +} diff --git a/apps/web/src/pages/voices/voices.css b/apps/web/src/pages/voices/voices.css index 24d8f1adf..df526af63 100644 --- a/apps/web/src/pages/voices/voices.css +++ b/apps/web/src/pages/voices/voices.css @@ -193,6 +193,24 @@ overflow: hidden; text-overflow: ellipsis; flex: 1; + display: flex; + align-items: center; +} + +/* AI 配音标识 */ +.vmat-ai-badge { + display: inline-block; + margin-left: 6px; + padding: 1px 6px; + font-size: 11px; + font-weight: 600; + color: #7c3aed; + background: #f3f0ff; + border: 1px solid #ddd6fe; + border-radius: 4px; + line-height: 16px; + vertical-align: middle; + flex-shrink: 0; } .xx-voice-star {