feat(voices): TTS时长修复 + 提取视频配音 + AI配音标识 #1656
@@ -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,
|
||||
|
||||
@@ -28,4 +28,5 @@ export {
|
||||
deleteTTSJob,
|
||||
getTtsVoices,
|
||||
previewTts,
|
||||
extractVideoVoice,
|
||||
} from "./jobs"
|
||||
|
||||
@@ -70,3 +70,56 @@ export const previewTts = async (data: TTSPreviewRequest): Promise<TTSPreviewRes
|
||||
const response = await apiClient.post<TTSPreviewResponse>("/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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 = () => {
|
||||
>
|
||||
上传音频
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="sm"
|
||||
icon={<VideoCameraOutlined />}
|
||||
onClick={() => setExtractOpen(true)}
|
||||
>
|
||||
提取视频配音
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
@@ -300,7 +327,18 @@ const VoiceLibrary: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── 弹窗集合 ──────────────────────────────────── */}
|
||||
{/* ── 视频提取配音弹窗 ─────────────────────────────── */}
|
||||
<VideoExtractModal
|
||||
open={extractOpen}
|
||||
file={extractFile}
|
||||
progress={extractProgress}
|
||||
isExtracting={isExtracting}
|
||||
onClose={handleExtractClose}
|
||||
onFileSelect={handleExtractFileSelect}
|
||||
onExtract={handleExtract}
|
||||
/>
|
||||
|
||||
{/* ── 弹窗集合 ─────────────────────────────────── */}
|
||||
<VoiceModals
|
||||
cloneModalOpen={cloneModalOpen}
|
||||
onCloneClose={() => setCloneModalOpen(false)}
|
||||
|
||||
@@ -136,6 +136,8 @@ export const MaterialVoiceTab: React.FC<MaterialVoiceTabProps> = ({
|
||||
const material = mapAssetToMaterial(asset)
|
||||
// duration 优先取顶层(后端从 metadata 提取),兜底 metadata
|
||||
const cardDuration = asset.duration || material.duration || 0
|
||||
// AI 生成素材标识(metadata.source === "tts_job")
|
||||
const isAiMaterial = (asset.metadata as Record<string, unknown>)?.source === "tts_job"
|
||||
const isPlaying = playingId === asset.id
|
||||
const isSelected = selectedIds.has(asset.id)
|
||||
// 播放中以 audio 真实时长为准,未播放显示卡片时长
|
||||
@@ -184,6 +186,7 @@ export const MaterialVoiceTab: React.FC<MaterialVoiceTabProps> = ({
|
||||
<div className="xx-voice-info vmat-info">
|
||||
<div className="xx-voice-name" title={asset.name}>
|
||||
{asset.name}
|
||||
{isAiMaterial && <span className="vmat-ai-badge">AI</span>}
|
||||
</div>
|
||||
<div className="xx-voice-subtitle">
|
||||
{asset.file_size ? `${formatFileSize(asset.file_size)}` : "--"}
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import React, { useRef } from "react"
|
||||
import { Modal } from "antd"
|
||||
import { InboxOutlined, CloseOutlined } from "@ant-design/icons"
|
||||
|
||||
interface VideoExtractModalProps {
|
||||
open: boolean
|
||||
file: File | null
|
||||
progress: number | null
|
||||
isExtracting: boolean
|
||||
onClose: () => void
|
||||
onFileSelect: (file: File | null) => void
|
||||
onExtract: () => void
|
||||
}
|
||||
|
||||
const ACCEPT_TYPES = ".mp4,.mov,.webm"
|
||||
|
||||
const VideoExtractModal: React.FC<VideoExtractModalProps> = ({
|
||||
open,
|
||||
file,
|
||||
progress,
|
||||
isExtracting,
|
||||
onClose,
|
||||
onFileSelect,
|
||||
onExtract,
|
||||
}) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={<span style={{ fontSize: 16, fontWeight: 600 }}>提取视频配音</span>}
|
||||
open={open}
|
||||
onCancel={() => {
|
||||
if (isExtracting) return
|
||||
onClose()
|
||||
}}
|
||||
footer={null}
|
||||
width={480}
|
||||
maskClosable={!isExtracting}
|
||||
>
|
||||
{!file ? (
|
||||
<div
|
||||
className="vmat-upload-dropzone"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
style={{
|
||||
border: "2px dashed #d9d9d9",
|
||||
borderRadius: 8,
|
||||
padding: "40px 20px",
|
||||
textAlign: "center",
|
||||
cursor: "pointer",
|
||||
transition: "border-color 0.3s",
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.borderColor = "#7c3aed")}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.borderColor = "#d9d9d9")}
|
||||
>
|
||||
<InboxOutlined style={{ fontSize: 32, color: "#7c3aed", marginBottom: 12 }} />
|
||||
<p style={{ margin: "0 0 8px", fontSize: 14, color: "#333" }}>点击选择视频文件</p>
|
||||
<span style={{ fontSize: 12, color: "#999" }}>支持 MP4、MOV、WebM 格式</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={ACCEPT_TYPES}
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0]
|
||||
if (f) onFileSelect(f)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "12px 16px",
|
||||
background: "#fafafa",
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
title={file.name}
|
||||
>
|
||||
{file.name}
|
||||
</span>
|
||||
<span style={{ fontSize: 12, color: "#999", marginLeft: 8, flexShrink: 0 }}>
|
||||
{(file.size / (1024 * 1024)).toFixed(1)} MB
|
||||
</span>
|
||||
{!isExtracting && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (inputRef.current) inputRef.current.value = ""
|
||||
onFileSelect(null)
|
||||
}}
|
||||
style={{
|
||||
border: "none",
|
||||
background: "none",
|
||||
cursor: "pointer",
|
||||
color: "#999",
|
||||
marginLeft: 8,
|
||||
fontSize: 14,
|
||||
}}
|
||||
aria-label="移除文件"
|
||||
>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{progress !== null && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div
|
||||
style={{
|
||||
height: 6,
|
||||
background: "#f0f0f0",
|
||||
borderRadius: 3,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
width: `${progress}%`,
|
||||
background: "linear-gradient(90deg, #7c3aed, #a78bfa)",
|
||||
borderRadius: 3,
|
||||
transition: "width 0.3s",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
textAlign: "right",
|
||||
fontSize: 12,
|
||||
color: "#999",
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
{progress}%
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isExtracting && (
|
||||
<p style={{ textAlign: "center", fontSize: 13, color: "#7c3aed", margin: "12px 0 0" }}>
|
||||
{progress === 100 ? "正在提取人声,请稍候..." : "正在上传视频..."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
gap: 8,
|
||||
marginTop: 24,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isExtracting}
|
||||
style={{
|
||||
padding: "6px 16px",
|
||||
borderRadius: 6,
|
||||
border: "1px solid #d9d9d9",
|
||||
background: "#fff",
|
||||
cursor: isExtracting ? "not-allowed" : "pointer",
|
||||
fontSize: 14,
|
||||
opacity: isExtracting ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExtract}
|
||||
disabled={!file || isExtracting}
|
||||
style={{
|
||||
padding: "6px 16px",
|
||||
borderRadius: 6,
|
||||
border: "none",
|
||||
background: !file || isExtracting ? "#d9d9d9" : "#7c3aed",
|
||||
color: "#fff",
|
||||
cursor: !file || isExtracting ? "not-allowed" : "pointer",
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{isExtracting ? "提取中..." : "开始提取"}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default VideoExtractModal
|
||||
@@ -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<File | null>(null)
|
||||
const [extractProgress, setExtractProgress] = useState<number | null>(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,
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user