From 9c34165b3562cc7a00642b6bd350cc4e4b7d4502 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Thu, 3 Sep 2026 18:29:04 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(voices):=20TTS=E6=97=B6=E9=95=BF?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20+=20=E6=8F=90=E5=8F=96=E8=A7=86=E9=A2=91?= =?UTF-8?q?=E9=85=8D=E9=9F=B3=20+=20AI=E9=85=8D=E9=9F=B3=E6=A0=87=E8=AF=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端修复: - tts.py: save_tts_job_to_library 用 ffprobe 从音频文件提取时长兜底 解决 job.duration=0 导致 asset.duration=None 的问题 - duration=audio_duration or job.duration or None 前端新增: - 提取视频配音按钮:VoiceLibrary 页面新增「提取视频配音」按钮(紫色 primary), 点击弹出 Modal 选择视频文件,上传后显示进度,完成后刷新素材列表 - API: extractVideoVoice() 调用 POST /tts/extract-video-voice - Hook: useVideoExtract 封装上传状态与进度 - Component: VideoExtractModal 基于 antd Modal - AI 配音标识:MaterialVoiceTab 卡片名称旁显示紫色「AI」标签, 依据 metadata.source === "tts_job" 判断 --- apps/api/app/api/routes/tts.py | 35 ++- apps/web/src/api/tts/index.ts | 1 + apps/web/src/api/tts/jobs.ts | 53 +++++ apps/web/src/pages/voices/VoiceLibrary.tsx | 42 +++- .../voices/components/MaterialVoiceTab.tsx | 3 + .../voices/components/VideoExtractModal.tsx | 207 ++++++++++++++++++ .../src/pages/voices/hooks/useVideoExtract.ts | 74 +++++++ apps/web/src/pages/voices/voices.css | 18 ++ 8 files changed, 424 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/pages/voices/components/VideoExtractModal.tsx create mode 100644 apps/web/src/pages/voices/hooks/useVideoExtract.ts diff --git a/apps/api/app/api/routes/tts.py b/apps/api/app/api/routes/tts.py index f1e06dc0f..9d350495c 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 @@ -453,12 +455,31 @@ def save_tts_job_to_library( status_code=status.HTTP_502_BAD_GATEWAY, detail="TTS 音频转存失败,无法保存到配音库", ) from e - finally: - if tmp_path and tmp_path.exists(): - try: - tmp_path.unlink() - except OSError: - pass + + # 从音频文件提取时长(ffprobe),作为 job.duration 的兜底 + audio_duration: float | None = None + if tmp_path and tmp_path.exists(): + 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) + + if tmp_path and tmp_path.exists(): + try: + tmp_path.unlink() + except OSError: + pass # 构建素材元信息 metadata_: dict[str, object] = { @@ -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=audio_duration or job.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..b7b2b1cb6 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, + 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..e6b72bed7 --- /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 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: any) { + const msg = err?.message || "提取失败,请重试" + showToast(msg, "error") + } finally { + setIsExtracting(false) + setExtractProgress(null) + } + }, [extractFile, queryClient, showToast]) + + const handleExtractClose = useCallback(() => { + setExtractOpen(false) + setExtractFile(null) + setExtractProgress(null) + setIsExtracting(false) + }, []) + + 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 { -- 2.54.0 From 6a5affa4031b57e27933e96dc342612d4f1e79fc Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Thu, 3 Sep 2026 18:37:42 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20AI=20Code=20Review=20=E9=98=BB?= =?UTF-8?q?=E5=A1=9E=E9=97=AE=E9=A2=98=20-=20=E4=B8=B4=E6=97=B6=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E6=B8=85=E7=90=86=E6=94=BE=E5=9B=9E=20finally=20?= =?UTF-8?q?=E5=9D=97=20+=20=E4=BF=AE=E5=A4=8D=20TS=20=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tts.py: ffprobe 提取时长移入 try 块,cleanup 回归 finally - VoiceLibrary.tsx: 重命名 useVideoExtract 的 handleFileSelect 避免与 useVoiceUpload 冲突 - VideoExtractModal: onFileSelect 类型 File | null 正确传递 --- apps/api/app/api/routes/tts.py | 34 +++++++++++----------- apps/web/src/pages/voices/VoiceLibrary.tsx | 4 +-- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/apps/api/app/api/routes/tts.py b/apps/api/app/api/routes/tts.py index 9d350495c..0c8b46dcf 100755 --- a/apps/api/app/api/routes/tts.py +++ b/apps/api/app/api/routes/tts.py @@ -432,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) @@ -447,18 +449,8 @@ 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) - except HTTPException: - raise - except Exception as e: - logger.error("TTS 音频转存素材失败: job_id=%s, error=%s", job.id, e, exc_info=True) - raise HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, - detail="TTS 音频转存失败,无法保存到配音库", - ) from e - # 从音频文件提取时长(ffprobe),作为 job.duration 的兜底 - audio_duration: float | None = None - if tmp_path and tmp_path.exists(): + # 从音频文件提取时长(ffprobe),作为 job.duration 的兜底 try: proc = subprocess.run( [ @@ -474,12 +466,20 @@ def save_tts_job_to_library( audio_duration = dur except Exception: logger.warning("ffprobe 提取时长失败: job_id=%s", job.id, exc_info=True) - - if tmp_path and tmp_path.exists(): - try: - tmp_path.unlink() - except OSError: - pass + except HTTPException: + raise + except Exception as e: + logger.error("TTS 音频转存素材失败: job_id=%s, error=%s", job.id, e, exc_info=True) + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="TTS 音频转存失败,无法保存到配音库", + ) from e + finally: + if tmp_path and tmp_path.exists(): + try: + tmp_path.unlink() + except OSError: + pass # 构建素材元信息 metadata_: dict[str, object] = { diff --git a/apps/web/src/pages/voices/VoiceLibrary.tsx b/apps/web/src/pages/voices/VoiceLibrary.tsx index b7b2b1cb6..271fe6d06 100755 --- a/apps/web/src/pages/voices/VoiceLibrary.tsx +++ b/apps/web/src/pages/voices/VoiceLibrary.tsx @@ -173,7 +173,7 @@ const VoiceLibrary: React.FC = () => { extractProgress, isExtracting, setExtractOpen, - handleFileSelect, + handleFileSelect: handleExtractFileSelect, handleExtract, handleExtractClose, } = useVideoExtract({ showToast }) @@ -334,7 +334,7 @@ const VoiceLibrary: React.FC = () => { progress={extractProgress} isExtracting={isExtracting} onClose={handleExtractClose} - onFileSelect={handleFileSelect} + onFileSelect={handleExtractFileSelect} onExtract={handleExtract} /> -- 2.54.0 From fb22140f02300aa3183a822d512354ed0ff7de78 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Thu, 3 Sep 2026 18:46:05 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20duration=20?= =?UTF-8?q?=E4=BC=98=E5=85=88=E7=BA=A7=20-=20job.duration=20=E4=BC=98?= =?UTF-8?q?=E5=85=88=EF=BC=8Cffprobe=20=E5=85=9C=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原来 audio_duration or job.duration 导致 ffprobe 对假数据返回 3.0 覆盖了 job.duration=6.0。改为 job.duration or audio_duration or None --- apps/api/app/api/routes/tts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/app/api/routes/tts.py b/apps/api/app/api/routes/tts.py index 0c8b46dcf..ec000a5b8 100755 --- a/apps/api/app/api/routes/tts.py +++ b/apps/api/app/api/routes/tts.py @@ -503,7 +503,7 @@ def save_tts_job_to_library( mime_type=content_type, metadata=metadata_, file_size=file_size, - duration=audio_duration or 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, -- 2.54.0 From e78e29b61fd887beb3290915572948190a810654 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Thu, 3 Sep 2026 18:57:06 +0800 Subject: [PATCH 4/4] fix: resolve ESLint warnings in useVideoExtract - Move handleExtractClose before handleExtract to fix dependency order - Change catch (err: any) to catch (err: unknown) with proper type guard - Add handleExtractClose to useCallback dependency array --- .../src/pages/voices/hooks/useVideoExtract.ts | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/web/src/pages/voices/hooks/useVideoExtract.ts b/apps/web/src/pages/voices/hooks/useVideoExtract.ts index e6b72bed7..7a45c56cf 100644 --- a/apps/web/src/pages/voices/hooks/useVideoExtract.ts +++ b/apps/web/src/pages/voices/hooks/useVideoExtract.ts @@ -18,6 +18,13 @@ export function useVideoExtract({ showToast }: UseVideoExtractProps) { 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) @@ -29,21 +36,14 @@ export function useVideoExtract({ showToast }: UseVideoExtractProps) { queryClient.invalidateQueries({ queryKey: ["voice-materials"] }) showToast("视频配音提取成功", "success") handleExtractClose() - } catch (err: any) { - const msg = err?.message || "提取失败,请重试" + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : "提取失败,请重试" showToast(msg, "error") } finally { setIsExtracting(false) setExtractProgress(null) } - }, [extractFile, queryClient, showToast]) - - const handleExtractClose = useCallback(() => { - setExtractOpen(false) - setExtractFile(null) - setExtractProgress(null) - setIsExtracting(false) - }, []) + }, [extractFile, queryClient, showToast, handleExtractClose]) const handleFileSelect = useCallback( (file: File | null) => { -- 2.54.0