bb08d0f586
主要变更: - 修复 /tasks/script 路由 404(去掉重复 prefix) - 开发模式自动认证兜底(无需登录即可测试流程) - Docker 基础设施独立化(共用 db/redis) - 前端 API 端口改为 8081 - 新增 TTS/语音克隆、视频粗剪、音频混音等智剪功能 - 删除智影专属模块(avatar、model_usage、qiniu 上传等)
512 lines
16 KiB
Python
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-zj/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))
|