@@ -6,12 +6,26 @@
from __future__ import annotations
import logging
import shutil
import subprocess
import tempfile
import time
from pathlib import Path
from typing import Literal , Optional
from uuid import uuid4
from app . api . routes . _helpers import get_user_plan
from app . auth import AuthenticatedUser , get_current_user
from app . dependencies import get_audio_url_signer , get_cosyvoice_service , get_db_session , get_user_repository
from app . core . storage import get_storage_service
from app . dependencies import (
get_asset_library_repository ,
get_asset_repository ,
get_audio_url_signer ,
get_cosyvoice_service ,
get_db_session ,
get_project_repository ,
get_user_repository ,
)
from app . schemas . voice import (
PresetVoiceItemResponse ,
PresetVoiceListResponse ,
@@ -24,7 +38,7 @@ from app.schemas.voice_library import (
UpdateVoiceLibraryRequest ,
VoiceLibraryItemResponse ,
)
from fastapi import APIRouter , Depends , HTTPException , Query , Response , status
from fastapi import APIRouter , Depends , File , Form , HTTPException , Query , Response , UploadFile , status
from sqlalchemy . orm import Session
from packages . adapters . sqlalchemy_impl . voice_clone_profile_repository import SQLAlchemyVoiceCloneProfileRepository
@@ -40,8 +54,12 @@ from packages.application.voice_library.use_cases import (
QuotaExceededError ,
UpdateVoiceLibraryUseCase ,
)
from packages . domain import Asset , AssetStatus
from packages . domain . classification import AssetLibraryKind , ClassificationStatus
from packages . domain . entities import AssetLibrary
from packages . domain . preset_voices import PRESET_VOICES , get_preset_voice_by_id
from packages . ports . user_repository import UserRepository
from packages . shared . storage import SharedStorageService
router = APIRouter ( )
logger = logging . getLogger ( __name__ )
@@ -507,3 +525,243 @@ def delete_voice(
if not deleted :
raise HTTPException ( status_code = status . HTTP_404_NOT_FOUND , detail = " Voice not found " )
return
# ── 提取视频配音 ─────────────────────────────────────────────────────
# 支持的视频格式
EXTRACT_VIDEO_MIMES = frozenset ( { " video/mp4 " , " video/quicktime " , " video/webm " , " video/x-msvideo " } )
MAX_EXTRACT_SIZE = 500 * 1024 * 1024 # 500MB
@router.post (
" /extract-voice " ,
status_code = status . HTTP_201_CREATED ,
)
def extract_voice_from_video (
file : UploadFile = File ( . . . ) ,
project_id : str = Form ( . . . ) ,
authenticated_user : AuthenticatedUser = Depends ( get_current_user ) ,
project_repository = Depends ( get_project_repository ) ,
asset_library_repository = Depends ( get_asset_library_repository ) ,
asset_repository = Depends ( get_asset_repository ) ,
storage_service : SharedStorageService = Depends ( get_storage_service ) ,
sign_url = Depends ( get_audio_url_signer ) ,
) :
""" 从上传的视频中提取人声配音。
流程:
1. 接收视频文件(mp4/mov/webm)
2. ffmpeg 提取音频 + 降噪 + 编码为 mp3
3. 上传到 OSS,创建 Asset 记录到配音素材库
4. 返回素材信息(时长、文件大小、URL)
"""
user_id = authenticated_user . user . id
# 校验文件类型
content_type = file . content_type or " "
if content_type and content_type not in EXTRACT_VIDEO_MIMES :
# 兜底:按扩展名判断
ext = ( file . filename or " " ) . rsplit ( " . " , 1 ) [ - 1 ] . lower ( )
ext_to_mime = { " mp4 " : " video/mp4 " , " mov " : " video/quicktime " , " webm " : " video/webm " , " avi " : " video/x-msvideo " }
if ext not in ext_to_mime :
raise HTTPException (
status_code = status . HTTP_400_BAD_REQUEST ,
detail = " 仅支持 mp4/mov/webm/avi 格式的视频文件 " ,
)
content_type = ext_to_mime [ ext ]
# 找到(或自动创建)用户 voice 素材库(复用 TTS 的逻辑)
library = _find_or_create_voice_library_for_extract (
user_id = user_id ,
project_repository = project_repository ,
asset_library_repository = asset_library_repository ,
)
tmp_dir = None
try :
tmp_dir = Path ( tempfile . mkdtemp ( prefix = " voice_extract_ " ) )
video_path = tmp_dir / f " input_ { uuid4 ( ) . hex [ : 8 ] } _ { file . filename or ' video.mp4 ' } "
audio_path = tmp_dir / f " output_ { uuid4 ( ) . hex [ : 8 ] } .mp3 "
# 保存上传的视频到临时文件
with open ( video_path , " wb " ) as f :
total = 0
while chunk := file . file . read ( 1024 * 1024 ) : # 1MB chunks
total + = len ( chunk )
if total > MAX_EXTRACT_SIZE :
raise HTTPException (
status_code = status . HTTP_413_REQUEST_ENTITY_TOO_LARGE ,
detail = " 视频文件过大,最大支持 500MB " ,
)
f . write ( chunk )
if video_path . stat ( ) . st_size == 0 :
raise HTTPException ( status_code = status . HTTP_400_BAD_REQUEST , detail = " 视频文件为空 " )
# ffmpeg: 提取音频 + 降噪 + 编码 mp3
# 滤镜链:highpass(去低频噪声) → afftdn(FFT降噪) → lowpass(去高频噪声)
ffmpeg_cmd = [
" ffmpeg " ,
" -y " ,
" -i " ,
str ( video_path ) ,
" -vn " , # 不要视频
" -af " ,
" highpass=f=80,afftdn=nf=-25:tn=1,lowpass=f=8000 " ,
" -acodec " ,
" libmp3lame " ,
" -ab " ,
" 192k " ,
" -ar " ,
" 44100 " ,
" -ac " ,
" 1 " , # 单声道(人声足够)
str ( audio_path ) ,
]
result = subprocess . run (
ffmpeg_cmd ,
capture_output = True ,
timeout = 300 , # 5 分钟超时
)
if result . returncode != 0 :
stderr_text = result . stderr . decode ( " utf-8 " , errors = " replace " ) [ - 500 : ]
logger . error ( " ffmpeg 提取配音失败: %s " , stderr_text )
raise HTTPException (
status_code = status . HTTP_422_UNPROCESSABLE_ENTITY ,
detail = " 视频音频提取失败,可能该视频没有音轨或格式不支持 " ,
)
if not audio_path . exists ( ) or audio_path . stat ( ) . st_size == 0 :
raise HTTPException (
status_code = status . HTTP_422_UNPROCESSABLE_ENTITY ,
detail = " 音频提取结果为空 " ,
)
# 获取音频时长
duration = _get_audio_duration ( audio_path )
file_size = audio_path . stat ( ) . st_size
# 上传到 OSS
audio_ext = " mp3 "
storage_key = f " uploads/voice/extracted/ { uuid4 ( ) . hex } . { audio_ext } "
storage_service . upload_file ( audio_path , storage_key , content_type = " audio/mpeg " )
# 创建 Asset 记录
original_name = ( file . filename or " video " ) . rsplit ( " . " , 1 ) [ 0 ]
asset_name = f " { original_name } -配音 "
asset = Asset . create (
project_id = library . project_id ,
library_id = library . id ,
name = asset_name ,
storage_key = storage_key ,
mime_type = " audio/mpeg " ,
metadata = {
" source " : " video_extract " ,
" original_video " : file . filename or " unknown " ,
} ,
file_size = file_size ,
duration = duration ,
status = AssetStatus . READY ,
classification_status = ClassificationStatus . PENDING ,
uploaded_by_user_id = user_id ,
)
asset = asset_repository . create ( asset )
return {
" id " : asset . id ,
" name " : asset . name ,
" audio_url " : sign_url ( storage_key ) ,
" duration " : duration ,
" file_size " : file_size ,
" status " : " completed " ,
" source " : " video_extract " ,
}
except HTTPException :
raise
except subprocess . TimeoutExpired :
raise HTTPException (
status_code = status . HTTP_504_GATEWAY_TIMEOUT ,
detail = " 视频处理超时,请尝试较短的视频 " ,
)
except Exception as e :
logger . exception ( " 提取视频配音失败: %s " , e )
raise HTTPException (
status_code = status . HTTP_500_INTERNAL_SERVER_ERROR ,
detail = " 提取配音失败,请稍后重试 " ,
)
finally :
# 清理临时文件
if tmp_dir and Path ( tmp_dir ) . exists ( ) :
shutil . rmtree ( tmp_dir , ignore_errors = True )
def _find_or_create_voice_library_for_extract ( * , user_id , project_repository , asset_library_repository ) :
""" 为用户找到或创建 voice 素材库(与 TTS 保存逻辑一致)。 """
projects = project_repository . find_accessible_projects ( user_id )
if not projects :
raise HTTPException (
status_code = status . HTTP_400_BAD_REQUEST ,
detail = " 没有可用的项目,请先创建项目 " ,
)
for project in projects :
for lib in asset_library_repository . find_by_project ( project . id ) :
kind = lib . kind . value if hasattr ( lib . kind , " value " ) else lib . kind
if kind == AssetLibraryKind . VOICE . value :
return lib
# 自动创建
from sqlalchemy . exc import IntegrityError
project = projects [ 0 ]
library = AssetLibrary . create (
project_id = project . id ,
name = " 配音素材库 " ,
kind = AssetLibraryKind . VOICE ,
)
try :
return asset_library_repository . create ( library )
except IntegrityError :
session = getattr ( asset_library_repository , " session " , None )
if session is not None :
try :
session . rollback ( )
except Exception :
pass
for lib in asset_library_repository . find_by_project ( project . id ) :
kind = lib . kind . value if hasattr ( lib . kind , " value " ) else lib . kind
if kind == AssetLibraryKind . VOICE . value :
return lib
raise HTTPException (
status_code = status . HTTP_500_INTERNAL_SERVER_ERROR ,
detail = " 配音素材库创建失败 " ,
)
def _get_audio_duration ( audio_path : Path ) - > float :
""" 用 ffprobe 获取音频时长(秒)。 """
try :
result = subprocess . run (
[
" ffprobe " ,
" -v " ,
" quiet " ,
" -show_entries " ,
" format=duration " ,
" -of " ,
" csv=p=0 " ,
str ( audio_path ) ,
] ,
capture_output = True ,
timeout = 10 ,
)
if result . returncode == 0 and result . stdout . strip ( ) :
return float ( result . stdout . strip ( ) )
except ( ValueError , subprocess . TimeoutExpired ) :
pass
return 0.0