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

视频导出服务

文档版本:v2.0
最后更新:2026-01-29
合规状态 符合 jointo-tech-stack 规范


目录

  1. 服务概述
  2. 核心功能
  3. 数据模型
  4. Pydantic Schemas
  5. Repository 层
  6. Service 层
  7. API 路由
  8. 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 模型

# 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 规范