Files
meijiaka-zy/python-api/app/api/v1/video.py
T

512 lines
16 KiB
Python

"""
视频生成 API 路由
================
提供数字人视频、文生视频、图生视频功能。
基于 KlingAI API 实现。
"""
import logging
import uuid
from datetime import datetime
from pathlib import Path
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
from fastapi.responses import FileResponse, StreamingResponse
from pydantic import BaseModel, Field
from app.ai.providers.klingai_provider import KlingAIProvider
from app.config import get_settings
from app.core.config_loader import get_config_loader
from app.schemas.common import ApiResponse, success_response
from app.schemas.segment import Segment
from app.services.kling_video_service import get_kling_video_service
router = APIRouter(prefix="/video", tags=["Video"])
# 视频文件存储目录
VIDEO_STORAGE_DIR = Path("data/video")
VIDEO_STORAGE_DIR.mkdir(parents=True, exist_ok=True)
# 上传文件临时目录
UPLOAD_DIR = Path("data/uploads")
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
logger = logging.getLogger(__name__)
# ============ 数据模型 ============
class DigitalHuman(BaseModel):
"""数字人信息"""
id: str
name: str
desc: str
avatar_url: str | None = None
type: str = "preset" # preset, custom, upload
class VideoGenerateRequest(BaseModel):
"""视频生成请求"""
project_id: str = Field(..., description="项目ID")
human_id: int | None = Field(None, description="数字人主体ID(分镜类型使用)")
segments: list[Segment] = Field(..., description="分镜列表")
class VideoGenerateResponse(BaseModel):
"""视频生成响应"""
job_id: str = Field(..., description="作业ID")
task_id: str = Field(..., description="任务ID(与job_id相同)")
status: str = Field(..., description="作业状态")
message: str = Field(..., description="状态消息")
sse_url: str = Field(..., description="SSE进度流URL")
class VideoJobStatus(BaseModel):
"""视频作业状态"""
job_id: str
project_id: str
status: str # pending, processing, completed, partial, failed
progress: int
total_segments: int
completed_segments: int
failed_segments: int
created_at: float
updated_at: float
error_message: str | None = None
class ShotResult(BaseModel):
"""单个分镜结果"""
segment_id: str
type: str
status: str
task_id: str | None = None
video_url: str | None = None
local_path: str | None = None
error_message: str | None = None
class VideoJobDetail(BaseModel):
"""视频作业详情"""
job_id: str
project_id: str
status: str
progress: int
total_segments: int
completed_segments: int
failed_segments: int
segments: list[ShotResult]
created_at: float
updated_at: float
# ============ 内存存储 ============
# 数字人库
digital_humans_db: dict[str, DigitalHuman] = {
"dh_001": DigitalHuman(
id="dh_001",
name="商务男士",
desc="专业稳重的商务形象,适合正式场合",
type="preset",
),
"dh_002": DigitalHuman(
id="dh_002",
name="亲和女士",
desc="温和亲切的女性形象,适合讲解分享",
type="preset",
),
"dh_003": DigitalHuman(
id="dh_003",
name="活力青年",
desc="年轻有活力的形象,适合轻松内容",
type="preset",
),
"dh_004": DigitalHuman(
id="dh_004", name="知性女性", desc="知性优雅的形象,适合知识分享", type="preset"
),
}
# ============ 辅助函数 ============
async def get_klingai_provider() -> KlingAIProvider:
"""获取 KlingAI Provider 实例
API Key 从 Settings 读取(符合配置规范)
"""
settings = get_settings()
config_loader = get_config_loader()
platform = config_loader.get_platform("klingai")
# 从 Settings 读取 AK/SK(符合配置规范:.env → Settings → 服务层)
access_key = settings.KLINGAI_ACCESS_KEY
secret_key = settings.KLINGAI_SECRET_KEY
if not access_key or not secret_key:
raise HTTPException(
status_code=400,
detail="KlingAI 未配置,请设置 KLINGAI_ACCESS_KEY 和 KLINGAI_SECRET_KEY",
)
# 从 YAML 读取 base_url(模型配置)
base_url = platform.base_url if platform else None
return KlingAIProvider(
{
"access_key": access_key,
"secret_key": secret_key,
"base_url": base_url or "https://api-beijing.klingai.com",
}
)
# ============ 新版 API 路由(推荐) ============
@router.post("/generate", response_model=ApiResponse[VideoGenerateResponse])
async def create_video_generation(data: VideoGenerateRequest):
"""
创建视频生成任务
接收项目ID、数字人ID和分镜列表,创建视频生成作业。
支持 SSE 流式查询进度。
**分镜类型说明:**
- `segment`: 分镜(带数字人),使用 omni-video 接口,需要 human_id
- `empty_shot`: 空镜,使用文生图 + 图生视频流程
**调用流程:**
1. 调用此接口创建任务,获取 job_id
2. 使用 SSE 接口 `/video/jobs/{job_id}/stream` 监听进度
3. 或使用 `/video/jobs/{job_id}` 查询状态
"""
try:
service = get_kling_video_service()
# 转换分镜数据
segments_data = []
for segment in data.segments:
segments_data.append(
{
"id": segment.id,
"type": segment.type,
"scene": segment.scene,
"voiceover": segment.voiceover,
"voice_id": segment.voice_id,
}
)
# 创建作业
job = await service.create_job(
project_id=data.project_id,
human_id=data.human_id,
segments_data=segments_data,
)
# 构建SSE URL
sse_url = f"/video/jobs/{job.job_id}/stream"
return success_response(
data=VideoGenerateResponse(
job_id=job.job_id,
task_id=job.job_id,
status=job.status,
message="视频生成任务已创建",
sse_url=sse_url,
)
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"创建视频生成任务失败: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/jobs/{job_id}", response_model=ApiResponse[VideoJobDetail])
async def get_video_job(job_id: str):
"""
查询视频生成作业详情
获取指定作业的详细信息和所有分镜的处理结果。
"""
try:
service = get_kling_video_service()
job = service.get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="作业不存在")
# 构建分镜结果
segments = []
for segment in job.segments:
segments.append(
ShotResult(
segment_id=segment.id,
type=segment.type,
status=segment.status,
task_id=segment.provider_task_id,
video_url=segment.video_url,
local_path=segment.local_path,
error_message=segment.error_message,
)
)
return success_response(
data=VideoJobDetail(
job_id=job.job_id,
project_id=job.project_id,
status=job.status,
progress=job.progress,
total_segments=len(job.segments),
completed_segments=sum(1 for s in job.segments if s.status.value == "completed"),
failed_segments=sum(1 for s in job.segments if s.status.value == "failed"),
segments=segments,
created_at=job.created_at,
updated_at=job.updated_at,
)
)
except HTTPException:
raise
except Exception as e:
logger.error(f"查询作业详情失败: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/jobs/{job_id}/stream")
async def stream_video_job(job_id: str):
"""
SSE 流式获取视频生成进度
使用 Server-Sent Events 实时推送视频生成进度。
**事件类型:**
- `start`: 开始生成
- `processing`: 处理中(包含进度信息)
- `finalizing`: 完成整理
- `complete`: 全部完成
- `error`: 发生错误
**示例:**
```
const eventSource = new EventSource('/api/v1/video/jobs/{job_id}/stream');
eventSource.onmessage = (e) => {
const data = JSON.parse(e.data);
console.log(data.progress + '%: ' + data.message);
};
```
"""
try:
service = get_kling_video_service()
# 验证作业存在
job = service.get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="作业不存在")
async def event_generator():
"""SSE 事件生成器"""
async for event in service.process_job_stream(job_id):
yield f"data: {__import__('json').dumps(event, ensure_ascii=False)}\n\n"
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
},
)
except HTTPException:
raise
except Exception as e:
logger.error(f"流式获取作业进度失败: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/jobs/{job_id}/status", response_model=ApiResponse[VideoJobStatus])
async def get_video_job_status(job_id: str):
"""
获取视频生成作业状态(简化版)
"""
try:
service = get_kling_video_service()
status = service.get_job_status(job_id)
if not status:
raise HTTPException(status_code=404, detail="作业不存在")
return success_response(
data=VideoJobStatus(
job_id=str(status["job_id"]),
project_id=str(status["project_id"]),
status=str(status["status"]),
progress=int(status["progress"]), # type: ignore[arg-type]
total_segments=int(status["total_segments"]), # type: ignore[arg-type]
completed_segments=int(status["completed_segments"]), # type: ignore[arg-type]
failed_segments=int(status["failed_segments"]), # type: ignore[arg-type]
created_at=float(status["created_at"]), # type: ignore[arg-type]
updated_at=float(status["updated_at"]), # type: ignore[arg-type]
error_message=status.get("error_message"),
)
)
except HTTPException:
raise
except Exception as e:
logger.error(f"获取作业状态失败: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ============ 数字人管理 ============
@router.get("/library", response_model=ApiResponse[list[DigitalHuman]])
async def get_digital_humans():
"""
获取数字人素材库
返回系统预设的数字人列表。
"""
try:
humans = list(digital_humans_db.values())
return success_response(data=humans)
except Exception as e:
logger.error(f"获取数字人库失败: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/upload", response_model=ApiResponse[DigitalHuman])
async def upload_video(
file: UploadFile = File(..., description="视频文件"),
name: str | None = Form(None, description="数字人名称"),
):
"""
上传人物视频作为数字人素材
文件要求:
- 格式:mp4, mov
- 时长:2-60秒
- 分辨率:720p 或 1080p
"""
try:
# 验证文件格式
allowed_types = ["video/mp4", "video/quicktime", "video/x-msvideo"]
if file.content_type not in allowed_types:
raise HTTPException(
status_code=400,
detail=f"不支持的文件格式: {file.content_type},请上传 mp4/mov 视频",
)
# 保存文件
file_ext = Path(file.filename or "").suffix or ".mp4"
video_id = f"upload_{uuid.uuid4().hex[:16]}"
video_filename = f"{video_id}{file_ext}"
video_path = UPLOAD_DIR / video_filename
content = await file.read()
video_path.write_bytes(content)
logger.info(f"视频上传成功: {video_path}, 大小: {len(content)} bytes")
# 创建数字人记录
human = DigitalHuman(
id=video_id,
name=name or f"上传视频_{datetime.now().strftime('%m%d_%H%M')}",
desc="用户上传的自定义数字人",
type="upload",
avatar_url=f"/api/v1/video/{video_id}/thumbnail",
)
# 添加到数据库
digital_humans_db[video_id] = human
return success_response(data=human)
except HTTPException:
raise
except Exception as e:
logger.error(f"上传视频失败: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{video_id}/download")
async def download_video(video_id: str):
"""
下载视频文件
支持三种查找位置:
1. data/video/{video_id}.mp4 - 传统存储
2. data/uploads/{video_id}.ext - 上传文件
3. ~/Documents/Meijiaka/projects/*/videos/{video_id}.mp4 - 项目生成的视频
文件名格式: scene_{shot_id}.mp4
"""
try:
# 1. 首先查找传统存储位置
video_path = VIDEO_STORAGE_DIR / f"{video_id}.mp4"
found = False
if not video_path.exists():
# 2. 尝试从上传目录查找
for ext in [".mp4", ".mov", ".avi"]:
candidate = UPLOAD_DIR / f"{video_id}{ext}"
if candidate.exists():
video_path = candidate
found = True
break
else:
found = True
# 3. 如果还没找到,尝试在项目视频目录中查找
# video_id 可能是 scene_{id} 格式
if not found:
from app.services.kling_video_service import KlingVideoService
# 遍历项目目录查找(递归查找)
base_dir = KlingVideoService.BASE_STORAGE_DIR
if base_dir.exists():
for project_dir in base_dir.iterdir():
if project_dir.is_dir():
candidate = project_dir / "videos" / f"{video_id}.mp4"
if candidate.exists():
video_path = candidate
found = True
break
if not found or not video_path.exists():
raise HTTPException(status_code=404, detail="视频文件不存在")
return FileResponse(path=video_path, media_type="video/mp4", filename=f"{video_id}.mp4")
except HTTPException:
raise
except Exception as e:
logger.error(f"下载视频失败: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{video_id}/thumbnail")
async def get_video_thumbnail(video_id: str):
"""
获取视频缩略图
"""
try:
# 简化实现:返回占位图
# 实际应该使用 FFmpeg 提取视频第一帧
raise HTTPException(status_code=404, detail="缩略图功能暂未实现")
except Exception as e:
logger.error(f"获取缩略图失败: {e}")
raise HTTPException(status_code=500, detail=str(e))