You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
45 KiB
45 KiB
视频导出服务
文档版本:v2.0
最后更新:2026-01-29
合规状态:✅ 符合 jointo-tech-stack 规范
目录
服务概述
视频导出服务负责将项目中的所有素材(视频、图片、音频、字幕等)按时间轴合成为最终视频文件。
职责
- 项目导出(JSON 格式)
- 视频合成(MP4、MOV 等格式)
- 质量控制(低、中、高)
- 导出任务管理
- 进度追踪
技术栈
- 异步数据库: AsyncSession + asyncpg
- 任务队列: Celery + RabbitMQ
- 视频处理: FFmpeg + ffmpeg-python
- 对象存储: MinIO
- 日志系统: structlog
核心功能
1. 项目导出
支持格式:
- JSON:导出项目配置和素材列表
- MP4:标准视频格式(H.264 + AAC)
- MOV:高质量视频格式
质量选项:
- low:快速导出,CRF 28,文件小
- medium:平衡质量和文件大小,CRF 23
- high:最佳质量,CRF 18,文件大
2. 视频合成
合成步骤:
- 获取项目所有素材
- 按时间轴排序
- 使用 FFmpeg 合成视频轨道
- 合成音频轨道
- 添加字幕(SRT 格式)
- 输出最终视频
- 上传到 MinIO
3. 任务管理
- 异步导出(Celery export 队列)
- 进度追踪(0-100%)
- 任务状态管理(pending/processing/completed/failed)
- 错误处理和自动重试(最多 2 次)
数据模型
ExportJob 模型
# app/models/export_job.py
from sqlmodel import SQLModel, Field, Column
from sqlalchemy import Text, SmallInteger
from datetime import datetime
from typing import Optional
from enum import IntEnum
class ExportStatus(IntEnum):
"""导出状态枚举"""
PENDING = 1 # 等待处理
PROCESSING = 2 # 正在处理
COMPLETED = 3 # 完成
FAILED = 4 # 失败
class ExportFormat(IntEnum):
"""导出格式枚举"""
JSON = 1
MP4 = 2
MOV = 3
class ExportQuality(IntEnum):
"""导出质量枚举"""
LOW = 1
MEDIUM = 2
HIGH = 3
class ExportJob(SQLModel, table=True):
"""导出任务表"""
__tablename__ = "export_jobs"
__table_args__ = {"comment": "视频导出任务表"}
id: str = Field(primary_key=True, description="任务 ID (UUID v7)")
project_id: str = Field(index=True, description="项目 ID")
user_id: str = Field(index=True, description="用户 ID")
status: int = Field(
sa_column=Column(SmallInteger, nullable=False, default=ExportStatus.PENDING),
description="任务状态: 1=pending, 2=processing, 3=completed, 4=failed"
)
format: int = Field(
sa_column=Column(SmallInteger, nullable=False),
description="导出格式: 1=json, 2=mp4, 3=mov"
)
quality: int = Field(
sa_column=Column(SmallInteger, nullable=False, default=ExportQuality.MEDIUM),
description="导出质量: 1=low, 2=medium, 3=high"
)
progress: int = Field(default=0, description="进度百分比 (0-100)")
output_url: Optional[str] = Field(default=None, max_length=500, description="输出文件 URL")
file_size: Optional[int] = Field(default=None, description="文件大小(字节)")
error_message: Optional[str] = Field(
default=None,
sa_column=Column(Text),
description="错误信息"
)
celery_task_id: Optional[str] = Field(default=None, max_length=255, description="Celery 任务 ID")
created_at: datetime = Field(
sa_column=Column(
"created_at",
sa_type=TIMESTAMP(timezone=True),
nullable=False,
server_default=text("CURRENT_TIMESTAMP")
),
description="创建时间"
)
updated_at: datetime = Field(
sa_column=Column(
"updated_at",
sa_type=TIMESTAMP(timezone=True),
nullable=False,
server_default=text("CURRENT_TIMESTAMP"),
onupdate=text("CURRENT_TIMESTAMP")
),
description="更新时间"
)
completed_at: Optional[datetime] = Field(
default=None,
sa_column=Column(TIMESTAMP(timezone=True)),
description="完成时间"
)
Pydantic Schemas
# app/schemas/export.py
from pydantic import BaseModel, Field, field_validator
from datetime import datetime
from typing import Optional
from enum import Enum
class ExportFormatEnum(str, Enum):
"""导出格式"""
JSON = "json"
MP4 = "mp4"
MOV = "mov"
class ExportQualityEnum(str, Enum):
"""导出质量"""
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
class ExportStatusEnum(str, Enum):
"""导出状态"""
PENDING = "pending"
PROCESSING = "processing"
COMPLETED = "completed"
FAILED = "failed"
# ============ 请求 Schemas ============
class ExportCreateRequest(BaseModel):
"""创建导出任务请求"""
project_id: str = Field(..., description="项目 ID")
format: ExportFormatEnum = Field(default=ExportFormatEnum.MP4, description="导出格式")
quality: ExportQualityEnum = Field(default=ExportQualityEnum.MEDIUM, description="导出质量")
@field_validator("project_id")
@classmethod
def validate_project_id(cls, v: str) -> str:
"""验证项目 ID 格式"""
if not v or len(v) != 36:
raise ValueError("无效的项目 ID")
return v
# ============ 响应 Schemas ============
class ExportJobResponse(BaseModel):
"""导出任务响应"""
id: str = Field(..., description="任务 ID")
project_id: str = Field(..., description="项目 ID")
user_id: str = Field(..., description="用户 ID")
status: ExportStatusEnum = Field(..., description="任务状态")
format: ExportFormatEnum = Field(..., description="导出格式")
quality: ExportQualityEnum = Field(..., description="导出质量")
progress: int = Field(..., description="进度百分比")
output_url: Optional[str] = Field(None, description="输出文件 URL")
file_size: Optional[int] = Field(None, description="文件大小(字节)")
error_message: Optional[str] = Field(None, description="错误信息")
created_at: datetime = Field(..., description="创建时间")
updated_at: datetime = Field(..., description="更新时间")
completed_at: Optional[datetime] = Field(None, description="完成时间")
class Config:
from_attributes = True
class ExportCreateResponse(BaseModel):
"""创建导出任务响应"""
job_id: str = Field(..., description="任务 ID")
task_id: str = Field(..., description="Celery 任务 ID")
status: ExportStatusEnum = Field(..., description="任务状态")
# ============ 内部数据结构 ============
class MaterialItem(BaseModel):
"""素材项"""
type: str = Field(..., description="素材类型: video/audio/subtitle")
url: Optional[str] = Field(None, description="素材 URL")
text: Optional[str] = Field(None, description="字幕文本")
start_time: float = Field(..., description="开始时间(秒)")
end_time: float = Field(..., description="结束时间(秒)")
duration: Optional[float] = Field(None, description="持续时间(秒)")
volume: Optional[int] = Field(None, description="音量 (0-100)")
style: Optional[dict] = Field(None, description="样式配置")
Repository 层
# app/repositories/export_job_repository.py
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update
from app.models.export_job import ExportJob, ExportStatus, ExportFormat, ExportQuality
from app.core.logging import get_logger
from typing import Optional, List
from datetime import datetime
import uuid
logger = get_logger(__name__)
class ExportJobRepository:
"""导出任务仓储"""
def __init__(self, async_session: AsyncSession):
self.async_session = async_session
async def create(
self,
project_id: str,
user_id: str,
format: int,
quality: int,
celery_task_id: str
) -> ExportJob:
"""创建导出任务"""
job = ExportJob(
id=str(uuid.uuid7()),
project_id=project_id,
user_id=user_id,
status=ExportStatus.PENDING,
format=format,
quality=quality,
progress=0,
celery_task_id=celery_task_id
)
self.async_session.add(job)
await self.async_session.commit()
await self.async_session.refresh(job)
logger.info(
"export_job_created",
job_id=job.id,
project_id=project_id,
user_id=user_id,
format=format
)
return job
async def get_by_id(self, job_id: str) -> Optional[ExportJob]:
"""根据 ID 获取任务"""
stmt = select(ExportJob).where(ExportJob.id == job_id)
result = await self.async_session.execute(stmt)
return result.scalar_one_or_none()
async def get_by_user(
self,
user_id: str,
limit: int = 20,
offset: int = 0
) -> List[ExportJob]:
"""获取用户的导出任务列表"""
stmt = (
select(ExportJob)
.where(ExportJob.user_id == user_id)
.order_by(ExportJob.created_at.desc())
.limit(limit)
.offset(offset)
)
result = await self.async_session.execute(stmt)
return list(result.scalars().all())
async def update_status(
self,
job_id: str,
status: int,
progress: Optional[int] = None,
output_url: Optional[str] = None,
file_size: Optional[int] = None,
error_message: Optional[str] = None
) -> bool:
"""更新任务状态"""
update_data = {"status": status}
if progress is not None:
update_data["progress"] = progress
if output_url is not None:
update_data["output_url"] = output_url
if file_size is not None:
update_data["file_size"] = file_size
if error_message is not None:
update_data["error_message"] = error_message
# 如果状态是完成或失败,设置完成时间
if status in (ExportStatus.COMPLETED, ExportStatus.FAILED):
update_data["completed_at"] = datetime.utcnow()
stmt = (
update(ExportJob)
.where(ExportJob.id == job_id)
.values(**update_data)
)
result = await self.async_session.execute(stmt)
await self.async_session.commit()
logger.info(
"export_job_status_updated",
job_id=job_id,
status=status,
progress=progress
)
return result.rowcount > 0
async def delete(self, job_id: str) -> bool:
"""删除导出任务"""
job = await self.get_by_id(job_id)
if not job:
return False
await self.async_session.delete(job)
await self.async_session.commit()
logger.info("export_job_deleted", job_id=job_id)
return True
Service 层
# app/services/export_service.py
from sqlalchemy.ext.asyncio import AsyncSession
from app.repositories.export_job_repository import ExportJobRepository
from app.repositories.project_repository import ProjectRepository
from app.models.export_job import ExportStatus, ExportFormat, ExportQuality
from app.schemas.export import (
ExportCreateRequest,
ExportCreateResponse,
ExportJobResponse,
ExportFormatEnum,
ExportQualityEnum,
ExportStatusEnum,
MaterialItem
)
from app.core.exceptions import ResourceNotFoundError, PermissionDeniedError, ValidationError
from app.core.logging import get_logger
from app.tasks.export_tasks import export_video_task, export_json_task
from typing import List
from celery.result import AsyncResult
logger = get_logger(__name__)
class ExportService:
"""导出服务"""
def __init__(
self,
async_session: AsyncSession,
export_job_repository: ExportJobRepository,
project_repository: ProjectRepository
):
self.async_session = async_session
self.export_job_repository = export_job_repository
self.project_repository = project_repository
async def create_export_job(
self,
user_id: str,
request: ExportCreateRequest
) -> ExportCreateResponse:
"""创建导出任务"""
logger.info(
"creating_export_job",
user_id=user_id,
project_id=request.project_id,
format=request.format,
quality=request.quality
)
# 1. 检查项目是否存在
project = await self.project_repository.get_by_id(request.project_id)
if not project:
logger.warning("project_not_found", project_id=request.project_id)
raise ResourceNotFoundError("项目不存在")
# 2. 检查用户权限(至少需要 viewer 权限)
if project.user_id != user_id:
logger.warning(
"permission_denied",
user_id=user_id,
project_id=request.project_id
)
raise PermissionDeniedError("没有权限导出此项目")
# 3. 转换枚举值
format_int = self._format_to_int(request.format)
quality_int = self._quality_to_int(request.quality)
# 4. 提交 Celery 任务
if request.format == ExportFormatEnum.JSON:
task = export_json_task.delay(
project_id=request.project_id,
user_id=user_id
)
else:
task = export_video_task.delay(
project_id=request.project_id,
user_id=user_id,
format=request.format.value,
quality=request.quality.value
)
# 5. 创建任务记录
job = await self.export_job_repository.create(
project_id=request.project_id,
user_id=user_id,
format=format_int,
quality=quality_int,
celery_task_id=task.id
)
logger.info(
"export_job_created",
job_id=job.id,
task_id=task.id,
project_id=request.project_id
)
return ExportCreateResponse(
job_id=job.id,
task_id=task.id,
status=ExportStatusEnum.PENDING
)
async def get_export_job(
self,
user_id: str,
job_id: str
) -> ExportJobResponse:
"""获取导出任务详情"""
job = await self.export_job_repository.get_by_id(job_id)
if not job:
logger.warning("export_job_not_found", job_id=job_id)
raise ResourceNotFoundError("导出任务不存在")
# 检查权限
if job.user_id != user_id:
logger.warning("permission_denied", user_id=user_id, job_id=job_id)
raise PermissionDeniedError("没有权限查看此任务")
return self._job_to_response(job)
async def list_export_jobs(
self,
user_id: str,
limit: int = 20,
offset: int = 0
) -> List[ExportJobResponse]:
"""获取用户的导出任务列表"""
jobs = await self.export_job_repository.get_by_user(
user_id=user_id,
limit=limit,
offset=offset
)
return [self._job_to_response(job) for job in jobs]
async def cancel_export_job(
self,
user_id: str,
job_id: str
) -> bool:
"""取消导出任务"""
job = await self.export_job_repository.get_by_id(job_id)
if not job:
raise ResourceNotFoundError("导出任务不存在")
if job.user_id != user_id:
raise PermissionDeniedError("没有权限取消此任务")
# 只能取消 pending 或 processing 状态的任务
if job.status not in (ExportStatus.PENDING, ExportStatus.PROCESSING):
raise ValidationError("任务已完成或失败,无法取消")
# 取消 Celery 任务
if job.celery_task_id:
AsyncResult(job.celery_task_id).revoke(terminate=True)
# 更新任务状态
await self.export_job_repository.update_status(
job_id=job_id,
status=ExportStatus.FAILED,
error_message="用户取消"
)
logger.info("export_job_cancelled", job_id=job_id, user_id=user_id)
return True
async def get_project_materials(
self,
project_id: str
) -> List[MaterialItem]:
"""获取项目所有素材(按时间轴排序)"""
# TODO: 实现获取项目素材逻辑
# 需要从 storyboard_videos, storyboard_audios, storyboard_subtitles 等表获取
logger.info("getting_project_materials", project_id=project_id)
materials: List[MaterialItem] = []
# 这里需要根据实际的数据模型实现
# 示例结构:
# materials.append(MaterialItem(
# type="video",
# url="https://...",
# start_time=0.0,
# end_time=5.0,
# duration=5.0
# ))
# 按开始时间排序
materials.sort(key=lambda x: x.start_time)
return materials
# ============ 私有方法 ============
def _job_to_response(self, job) -> ExportJobResponse:
"""转换任务模型为响应 Schema"""
return ExportJobResponse(
id=job.id,
project_id=job.project_id,
user_id=job.user_id,
status=self._int_to_status(job.status),
format=self._int_to_format(job.format),
quality=self._int_to_quality(job.quality),
progress=job.progress,
output_url=job.output_url,
file_size=job.file_size,
error_message=job.error_message,
created_at=job.created_at,
updated_at=job.updated_at,
completed_at=job.completed_at
)
def _format_to_int(self, format: ExportFormatEnum) -> int:
"""格式枚举转整数"""
mapping = {
ExportFormatEnum.JSON: ExportFormat.JSON,
ExportFormatEnum.MP4: ExportFormat.MP4,
ExportFormatEnum.MOV: ExportFormat.MOV
}
return mapping[format]
def _int_to_format(self, format_int: int) -> ExportFormatEnum:
"""整数转格式枚举"""
mapping = {
ExportFormat.JSON: ExportFormatEnum.JSON,
ExportFormat.MP4: ExportFormatEnum.MP4,
ExportFormat.MOV: ExportFormatEnum.MOV
}
return mapping[format_int]
def _quality_to_int(self, quality: ExportQualityEnum) -> int:
"""质量枚举转整数"""
mapping = {
ExportQualityEnum.LOW: ExportQuality.LOW,
ExportQualityEnum.MEDIUM: ExportQuality.MEDIUM,
ExportQualityEnum.HIGH: ExportQuality.HIGH
}
return mapping[quality]
def _int_to_quality(self, quality_int: int) -> ExportQualityEnum:
"""整数转质量枚举"""
mapping = {
ExportQuality.LOW: ExportQualityEnum.LOW,
ExportQuality.MEDIUM: ExportQualityEnum.MEDIUM,
ExportQuality.HIGH: ExportQualityEnum.HIGH
}
return mapping[quality_int]
def _int_to_status(self, status_int: int) -> ExportStatusEnum:
"""整数转状态枚举"""
mapping = {
ExportStatus.PENDING: ExportStatusEnum.PENDING,
ExportStatus.PROCESSING: ExportStatusEnum.PROCESSING,
ExportStatus.COMPLETED: ExportStatusEnum.COMPLETED,
ExportStatus.FAILED: ExportStatusEnum.FAILED
}
return mapping[status_int]
API 路由
# app/api/v1/export.py
from fastapi import APIRouter, Depends, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_async_session
from app.core.auth import get_current_user
from app.schemas.user import UserResponse
from app.schemas.export import (
ExportCreateRequest,
ExportCreateResponse,
ExportJobResponse
)
from app.schemas.common import SuccessResponse, PaginatedResponse
from app.services.export_service import ExportService
from app.repositories.export_job_repository import ExportJobRepository
from app.repositories.project_repository import ProjectRepository
from app.core.logging import get_logger
from typing import List
logger = get_logger(__name__)
router = APIRouter(prefix="/export", tags=["export"])
def get_export_service(
async_session: AsyncSession = Depends(get_async_session)
) -> ExportService:
"""获取导出服务实例"""
export_job_repo = ExportJobRepository(async_session)
project_repo = ProjectRepository(async_session)
return ExportService(async_session, export_job_repo, project_repo)
@router.post(
"",
response_model=SuccessResponse[ExportCreateResponse],
status_code=status.HTTP_201_CREATED,
summary="创建导出任务",
description="创建视频导出任务,支持 JSON、MP4、MOV 格式"
)
async def create_export_job(
request: ExportCreateRequest,
current_user: UserResponse = Depends(get_current_user),
export_service: ExportService = Depends(get_export_service)
):
"""创建导出任务"""
logger.info(
"api_create_export_job",
user_id=current_user.id,
project_id=request.project_id
)
result = await export_service.create_export_job(
user_id=current_user.id,
request=request
)
return SuccessResponse(data=result)
@router.get(
"/jobs/{job_id}",
response_model=SuccessResponse[ExportJobResponse],
summary="获取导出任务详情",
description="查询导出任务的状态和进度"
)
async def get_export_job(
job_id: str,
current_user: UserResponse = Depends(get_current_user),
export_service: ExportService = Depends(get_export_service)
):
"""获取导出任务详情"""
logger.info("api_get_export_job", user_id=current_user.id, job_id=job_id)
result = await export_service.get_export_job(
user_id=current_user.id,
job_id=job_id
)
return SuccessResponse(data=result)
@router.get(
"/jobs",
response_model=SuccessResponse[List[ExportJobResponse]],
summary="获取导出任务列表",
description="获取当前用户的所有导出任务"
)
async def list_export_jobs(
limit: int = 20,
offset: int = 0,
current_user: UserResponse = Depends(get_current_user),
export_service: ExportService = Depends(get_export_service)
):
"""获取导出任务列表"""
logger.info("api_list_export_jobs", user_id=current_user.id)
result = await export_service.list_export_jobs(
user_id=current_user.id,
limit=limit,
offset=offset
)
return SuccessResponse(data=result)
@router.delete(
"/jobs/{job_id}",
response_model=SuccessResponse[bool],
summary="取消导出任务",
description="取消正在进行的导出任务"
)
async def cancel_export_job(
job_id: str,
current_user: UserResponse = Depends(get_current_user),
export_service: ExportService = Depends(get_export_service)
):
"""取消导出任务"""
logger.info("api_cancel_export_job", user_id=current_user.id, job_id=job_id)
result = await export_service.cancel_export_job(
user_id=current_user.id,
job_id=job_id
)
return SuccessResponse(data=result)
Celery 任务
# app/tasks/export_tasks.py
from app.core.celery_app import celery_app
from app.core.database import AsyncSessionLocal
from app.core.storage import get_storage_service
from app.repositories.export_job_repository import ExportJobRepository
from app.models.export_job import ExportStatus
from app.utils.video_composer import VideoComposer
from app.core.logging import get_logger
import os
import json
from typing import Dict, Any
logger = get_logger(__name__)
@celery_app.task(
bind=True,
max_retries=2,
default_retry_delay=300, # 5 分钟
queue="export"
)
def export_video_task(
self,
project_id: str,
user_id: str,
format: str,
quality: str
) -> Dict[str, Any]:
"""导出视频任务"""
job_id = None
try:
logger.info(
"export_video_task_started",
task_id=self.request.id,
project_id=project_id,
format=format,
quality=quality
)
# 1. 获取任务记录
async def get_job():
async with AsyncSessionLocal() as session:
repo = ExportJobRepository(session)
# 通过 celery_task_id 查找任务
from sqlalchemy import select
from app.models.export_job import ExportJob
stmt = select(ExportJob).where(
ExportJob.celery_task_id == self.request.id
)
result = await session.execute(stmt)
return result.scalar_one_or_none()
import asyncio
job = asyncio.run(get_job())
if not job:
logger.error("export_job_not_found", task_id=self.request.id)
raise ValueError("导出任务记录不存在")
job_id = job.id
# 2. 更新任务状态为处理中
async def update_processing():
async with AsyncSessionLocal() as session:
repo = ExportJobRepository(session)
await repo.update_status(
job_id=job_id,
status=ExportStatus.PROCESSING,
progress=0
)
asyncio.run(update_processing())
# 3. 获取项目素材
async def get_materials():
async with AsyncSessionLocal() as session:
from app.services.export_service import ExportService
from app.repositories.project_repository import ProjectRepository
export_repo = ExportJobRepository(session)
project_repo = ProjectRepository(session)
service = ExportService(session, export_repo, project_repo)
return await service.get_project_materials(project_id)
materials = asyncio.run(get_materials())
logger.info(
"materials_retrieved",
job_id=job_id,
material_count=len(materials)
)
# 4. 合成视频
output_path = f"/tmp/export_{job_id}.{format}"
composer = VideoComposer()
asyncio.run(composer.compose(
materials=materials,
output_path=output_path,
format=format,
quality=quality
))
# 更新进度
async def update_progress_80():
async with AsyncSessionLocal() as session:
repo = ExportJobRepository(session)
await repo.update_status(
job_id=job_id,
status=ExportStatus.PROCESSING,
progress=80
)
asyncio.run(update_progress_80())
# 5. 上传到对象存储
storage = get_storage_service()
object_name = f"exports/{project_id}/{job_id}.{format}"
file_size = os.path.getsize(output_path)
output_url = asyncio.run(storage.upload_file(
file_path=output_path,
object_name=object_name,
content_type=f"video/{format}"
))
logger.info(
"video_uploaded",
job_id=job_id,
output_url=output_url,
file_size=file_size
)
# 6. 清理临时文件
if os.path.exists(output_path):
os.remove(output_path)
# 7. 更新任务完成
async def update_completed():
async with AsyncSessionLocal() as session:
repo = ExportJobRepository(session)
await repo.update_status(
job_id=job_id,
status=ExportStatus.COMPLETED,
progress=100,
output_url=output_url,
file_size=file_size
)
asyncio.run(update_completed())
logger.info("export_video_task_completed", job_id=job_id)
return {
"job_id": job_id,
"output_url": output_url,
"file_size": file_size
}
except Exception as exc:
logger.error(
"export_video_task_failed",
job_id=job_id,
error=str(exc),
exc_info=True
)
# 更新任务失败状态
if job_id:
async def update_failed():
async with AsyncSessionLocal() as session:
repo = ExportJobRepository(session)
await repo.update_status(
job_id=job_id,
status=ExportStatus.FAILED,
error_message=str(exc)
)
asyncio.run(update_failed())
# 重试
raise self.retry(exc=exc)
@celery_app.task(
bind=True,
max_retries=2,
default_retry_delay=60,
queue="export"
)
def export_json_task(
self,
project_id: str,
user_id: str
) -> Dict[str, Any]:
"""导出 JSON 任务"""
job_id = None
try:
logger.info(
"export_json_task_started",
task_id=self.request.id,
project_id=project_id
)
# 1. 获取任务记录
async def get_job():
async with AsyncSessionLocal() as session:
repo = ExportJobRepository(session)
from sqlalchemy import select
from app.models.export_job import ExportJob
stmt = select(ExportJob).where(
ExportJob.celery_task_id == self.request.id
)
result = await session.execute(stmt)
return result.scalar_one_or_none()
import asyncio
job = asyncio.run(get_job())
if not job:
raise ValueError("导出任务记录不存在")
job_id = job.id
# 2. 更新状态
async def update_processing():
async with AsyncSessionLocal() as session:
repo = ExportJobRepository(session)
await repo.update_status(
job_id=job_id,
status=ExportStatus.PROCESSING,
progress=0
)
asyncio.run(update_processing())
# 3. 获取项目数据
async def get_project_data():
async with AsyncSessionLocal() as session:
from app.repositories.project_repository import ProjectRepository
repo = ProjectRepository(session)
project = await repo.get_by_id(project_id)
if not project:
raise ValueError("项目不存在")
# 获取素材
from app.services.export_service import ExportService
export_repo = ExportJobRepository(session)
service = ExportService(session, export_repo, repo)
materials = await service.get_project_materials(project_id)
return {
"project": {
"id": project.id,
"name": project.name,
"description": project.description,
"created_at": project.created_at.isoformat(),
"updated_at": project.updated_at.isoformat()
},
"materials": [m.dict() for m in materials]
}
project_data = asyncio.run(get_project_data())
# 4. 生成 JSON 文件
output_path = f"/tmp/export_{job_id}.json"
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(project_data, f, ensure_ascii=False, indent=2)
file_size = os.path.getsize(output_path)
# 5. 上传到对象存储
storage = get_storage_service()
object_name = f"exports/{project_id}/{job_id}.json"
output_url = asyncio.run(storage.upload_file(
file_path=output_path,
object_name=object_name,
content_type="application/json"
))
# 6. 清理临时文件
if os.path.exists(output_path):
os.remove(output_path)
# 7. 更新任务完成
async def update_completed():
async with AsyncSessionLocal() as session:
repo = ExportJobRepository(session)
await repo.update_status(
job_id=job_id,
status=ExportStatus.COMPLETED,
progress=100,
output_url=output_url,
file_size=file_size
)
asyncio.run(update_completed())
logger.info("export_json_task_completed", job_id=job_id)
return {
"job_id": job_id,
"output_url": output_url,
"file_size": file_size
}
except Exception as exc:
logger.error(
"export_json_task_failed",
job_id=job_id,
error=str(exc),
exc_info=True
)
if job_id:
async def update_failed():
async with AsyncSessionLocal() as session:
repo = ExportJobRepository(session)
await repo.update_status(
job_id=job_id,
status=ExportStatus.FAILED,
error_message=str(exc)
)
asyncio.run(update_failed())
raise self.retry(exc=exc)
视频合成工具
# app/utils/video_composer.py
import ffmpeg
from typing import List, Dict, Any
from app.schemas.export import MaterialItem
from app.core.logging import get_logger
import os
import tempfile
logger = get_logger(__name__)
class VideoComposer:
"""视频合成器"""
def __init__(self):
self.quality_params = {
'low': {'crf': 28, 'preset': 'fast'},
'medium': {'crf': 23, 'preset': 'medium'},
'high': {'crf': 18, 'preset': 'slow'}
}
async def compose(
self,
materials: List[MaterialItem],
output_path: str,
format: str = 'mp4',
quality: str = 'medium'
) -> str:
"""合成视频"""
logger.info(
"video_composition_started",
material_count=len(materials),
format=format,
quality=quality
)
try:
# 1. 分离不同类型的素材
video_materials = [m for m in materials if m.type == 'video']
audio_materials = [m for m in materials if m.type == 'audio']
subtitle_materials = [m for m in materials if m.type == 'subtitle']
logger.info(
"materials_separated",
videos=len(video_materials),
audios=len(audio_materials),
subtitles=len(subtitle_materials)
)
# 2. 合成视频轨道
video_stream = await self._compose_video_track(video_materials)
# 3. 合成音频轨道
audio_stream = await self._compose_audio_track(audio_materials)
# 4. 添加字幕
if subtitle_materials:
video_stream = await self._add_subtitles(
video_stream,
subtitle_materials
)
# 5. 合并视频和音频
quality_params = self.quality_params[quality]
output = ffmpeg.output(
video_stream,
audio_stream,
output_path,
vcodec='libx264',
acodec='aac',
**quality_params
)
# 6. 执行 FFmpeg
logger.info("ffmpeg_execution_started", output_path=output_path)
ffmpeg.run(
output,
capture_stdout=True,
capture_stderr=True,
overwrite_output=True
)
logger.info(
"video_composition_completed",
output_path=output_path,
file_size=os.path.getsize(output_path)
)
return output_path
except ffmpeg.Error as e:
logger.error(
"ffmpeg_error",
stdout=e.stdout.decode('utf-8') if e.stdout else None,
stderr=e.stderr.decode('utf-8') if e.stderr else None,
exc_info=True
)
raise ValueError(f"视频合成失败: {e.stderr.decode('utf-8') if e.stderr else str(e)}")
except Exception as e:
logger.error("video_composition_failed", error=str(e), exc_info=True)
raise
async def _compose_video_track(
self,
video_materials: List[MaterialItem]
):
"""合成视频轨道"""
if not video_materials:
logger.warning("no_video_materials")
raise ValueError("没有视频素材")
logger.info("composing_video_track", count=len(video_materials))
# 创建视频输入流
inputs = []
for i, material in enumerate(video_materials):
try:
input_stream = ffmpeg.input(material.url)
# 裁剪视频片段
if material.start_time is not None and material.end_time is not None:
duration = material.end_time - material.start_time
input_stream = input_stream.trim(
start=material.start_time,
duration=duration
).setpts('PTS-STARTPTS')
inputs.append(input_stream)
except Exception as e:
logger.error(
"video_input_failed",
index=i,
url=material.url,
error=str(e)
)
raise
# 拼接视频
if len(inputs) == 1:
return inputs[0]
else:
return ffmpeg.concat(*inputs, v=1, a=0)
async def _compose_audio_track(
self,
audio_materials: List[MaterialItem]
):
"""合成音频轨道"""
if not audio_materials:
logger.info("no_audio_materials_creating_silent_track")
# 创建静音轨道
return ffmpeg.input('anullsrc', f='lavfi')
logger.info("composing_audio_track", count=len(audio_materials))
# 创建音频输入流
inputs = []
for i, material in enumerate(audio_materials):
try:
input_stream = ffmpeg.input(material.url)
# 调整音量
if material.volume is not None and material.volume != 100:
volume_factor = material.volume / 100
input_stream = input_stream.filter('volume', volume_factor)
inputs.append(input_stream)
except Exception as e:
logger.error(
"audio_input_failed",
index=i,
url=material.url,
error=str(e)
)
raise
# 混合音频
if len(inputs) == 1:
return inputs[0]
else:
return ffmpeg.filter(inputs, 'amix', inputs=len(inputs))
async def _add_subtitles(
self,
video_stream,
subtitle_materials: List[MaterialItem]
):
"""添加字幕"""
logger.info("adding_subtitles", count=len(subtitle_materials))
# 生成 SRT 字幕文件
with tempfile.NamedTemporaryFile(
mode='w',
suffix='.srt',
delete=False,
encoding='utf-8'
) as f:
srt_path = f.name
await self._generate_srt(subtitle_materials, f)
logger.info("srt_file_generated", path=srt_path)
# 使用 FFmpeg 添加字幕
return video_stream.filter('subtitles', srt_path)
async def _generate_srt(
self,
subtitle_materials: List[MaterialItem],
file_handle
):
"""生成 SRT 字幕文件"""
for i, subtitle in enumerate(subtitle_materials, 1):
# 序号
file_handle.write(f"{i}\n")
# 时间轴
start_time = self._format_srt_time(subtitle.start_time)
end_time = self._format_srt_time(subtitle.end_time)
file_handle.write(f"{start_time} --> {end_time}\n")
# 字幕文本
file_handle.write(f"{subtitle.text}\n\n")
def _format_srt_time(self, seconds: float) -> str:
"""格式化 SRT 时间"""
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
secs = int(seconds % 60)
millis = int((seconds % 1) * 1000)
return f"{hours:02d}:{minutes:02d}:{secs:02d},{millis:03d}"
错误处理
常见错误
| 错误码 | 错误信息 | 说明 |
|---|---|---|
| 404 | 项目不存在 | project_id 无效 |
| 403 | 没有权限导出此项目 | 用户不是项目所有者 |
| 400 | 不支持的导出格式 | format 参数无效 |
| 400 | 无效的质量选项 | quality 参数无效 |
| 500 | 视频合成失败 | FFmpeg 执行错误 |
| 500 | 文件上传失败 | MinIO 上传错误 |
错误日志示例
logger.error(
"export_video_task_failed",
job_id=job_id,
project_id=project_id,
error=str(exc),
exc_info=True
)
质量参数配置
FFmpeg 质量参数
| 质量 | CRF | Preset | 说明 | 预估文件大小 |
|---|---|---|---|---|
| low | 28 | fast | 快速编码,质量一般 | 1080p: ~50MB/分钟 |
| medium | 23 | medium | 平衡质量和速度 | 1080p: ~100MB/分钟 |
| high | 18 | slow | 高质量,编码慢 | 1080p: ~200MB/分钟 |
CRF(Constant Rate Factor):
- 范围:0-51
- 值越小,质量越高
- 推荐:18-28
Preset:
- ultrafast, superfast, veryfast, faster, fast
- medium
- slow, slower, veryslow
数据库迁移
# alembic/versions/YYYYMMDD_HHMM_create_export_jobs_table.py
"""create export_jobs table
Revision ID: xxx
Revises: xxx
Create Date: 2026-01-29 16:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import TIMESTAMP, UUID
# revision identifiers
revision = 'xxx'
down_revision = 'xxx'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
'export_jobs',
sa.Column('id', UUID(as_uuid=False), primary_key=True, comment='任务 ID (UUID v7)'),
sa.Column('project_id', UUID(as_uuid=False), nullable=False, index=True, comment='项目 ID'),
sa.Column('user_id', UUID(as_uuid=False), nullable=False, index=True, comment='用户 ID'),
sa.Column('status', sa.SmallInteger(), nullable=False, default=1, comment='任务状态: 1=pending, 2=processing, 3=completed, 4=failed'),
sa.Column('format', sa.SmallInteger(), nullable=False, comment='导出格式: 1=json, 2=mp4, 3=mov'),
sa.Column('quality', sa.SmallInteger(), nullable=False, default=2, comment='导出质量: 1=low, 2=medium, 3=high'),
sa.Column('progress', sa.Integer(), nullable=False, default=0, comment='进度百分比 (0-100)'),
sa.Column('output_url', sa.String(500), nullable=True, comment='输出文件 URL'),
sa.Column('file_size', sa.BigInteger(), nullable=True, comment='文件大小(字节)'),
sa.Column('error_message', sa.Text(), nullable=True, comment='错误信息'),
sa.Column('celery_task_id', sa.String(255), nullable=True, comment='Celery 任务 ID'),
sa.Column('created_at', TIMESTAMP(timezone=True), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP'), comment='创建时间'),
sa.Column('updated_at', TIMESTAMP(timezone=True), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP'), comment='更新时间'),
sa.Column('completed_at', TIMESTAMP(timezone=True), nullable=True, comment='完成时间'),
comment='视频导出任务表'
)
# 创建索引
op.create_index('idx_export_jobs_user_id', 'export_jobs', ['user_id'])
op.create_index('idx_export_jobs_project_id', 'export_jobs', ['project_id'])
op.create_index('idx_export_jobs_status', 'export_jobs', ['status'])
op.create_index('idx_export_jobs_celery_task_id', 'export_jobs', ['celery_task_id'])
def downgrade() -> None:
op.drop_table('export_jobs')
相关文档
文档版本:v2.0
最后更新:2026-01-29
合规状态:✅ 符合 jointo-tech-stack 规范