diff --git a/apps/api/app/api/routes/voices.py b/apps/api/app/api/routes/voices.py index d03e7fa76..b61103113 100755 --- a/apps/api/app/api/routes/voices.py +++ b/apps/api/app/api/routes/voices.py @@ -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 diff --git a/apps/worker/worker_app/tasks/ingest.py b/apps/worker/worker_app/tasks/ingest.py index 0a7aa4764..7b48f0f45 100755 --- a/apps/worker/worker_app/tasks/ingest.py +++ b/apps/worker/worker_app/tasks/ingest.py @@ -630,7 +630,7 @@ def ingest_asset(job_id: str) -> dict: name=filename, storage_key=job.storage_key, mime_type=mime_type, - metadata={"ingest_error": error_reason}, + metadata={"source": "upload", "ingest_error": error_reason}, file_size=int(metadata.get("size_bytes", 0)), duration=float(metadata.get("duration", 0)), width=int(metadata.get("width", 0)), @@ -666,6 +666,7 @@ def ingest_asset(job_id: str) -> dict: if existing_asset is None: # 兜底:如果 API 端没有预先创建 Asset(旧版本兼容),则创建新记录 logger.info("No pre-created asset found for storage_key=%s, creating new", job.storage_key) + metadata["source"] = "upload" asset = Asset.create( project_id=job.project_id, library_id=job.library_id, @@ -687,6 +688,7 @@ def ingest_asset(job_id: str) -> dict: # 更新已有的 Asset 记录,补充元数据并将状态改为 READY asset = existing_asset asset.mime_type = mime_type + metadata["source"] = "upload" asset.metadata = metadata asset.file_size = int(metadata.get("size_bytes", 0)) asset.duration = float(metadata.get("duration", 0))