# 视频导出服务 > **文档版本**:v2.0 > **最后更新**:2026-01-29 > **合规状态**:✅ 符合 jointo-tech-stack 规范 --- ## 目录 1. [服务概述](#服务概述) 2. [核心功能](#核心功能) 3. [数据模型](#数据模型) 4. [Pydantic Schemas](#pydantic-schemas) 5. [Repository 层](#repository-层) 6. [Service 层](#service-层) 7. [API 路由](#api-路由) 8. [Celery 任务](#celery-任务) 9. [视频合成工具](#视频合成工具) 10. [错误处理](#错误处理) --- ## 服务概述 视频导出服务负责将项目中的所有素材(视频、图片、音频、字幕等)按时间轴合成为最终视频文件。 ### 职责 - 项目导出(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. 视频合成 **合成步骤**: 1. 获取项目所有素材 2. 按时间轴排序 3. 使用 FFmpeg 合成视频轨道 4. 合成音频轨道 5. 添加字幕(SRT 格式) 6. 输出最终视频 7. 上传到 MinIO ### 3. 任务管理 - 异步导出(Celery export 队列) - 进度追踪(0-100%) - 任务状态管理(pending/processing/completed/failed) - 错误处理和自动重试(最多 2 次) --- ## 数据模型 ### ExportJob 模型 ```python # 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 ```python # 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 层 ```python # 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 层 ```python # 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 路由 ```python # 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 任务 ```python # 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) ``` --- ## 视频合成工具 ```python # 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 上传错误 | ### 错误日志示例 ```python 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 --- ## 数据库迁移 ```python # 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') ``` --- ## 相关文档 - [项目服务](./project-service.md) - [异步任务处理](../../07-async-tasks.md) - [文件存储方案](../../08-storage.md) - [日志系统](../../../server/rfcs/201-logging-system-migration.md) - [API 设计规范](../../../architecture/tech-stack.md) --- **文档版本**:v2.0 **最后更新**:2026-01-29 **合规状态**:✅ 符合 jointo-tech-stack 规范