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.
 

21 KiB

视频管理服务

文档版本:v1.1
最后更新:2025-01-27


目录

  1. 服务概述
  2. 核心功能
  3. 数据库设计
  4. 服务实现
  5. API 接口
  6. 数据模型

服务概述

视频管理服务负责处理项目中的视频资源,包括 AI 生成视频、上传视频、视频编辑等功能。

职责

  • 视频 CRUD 操作
  • 视频生成类型管理(文本转视频、图片转视频等)
  • 视频与分镜/项目关联
  • 视频状态跟踪(pending/processing/completed/failed)
  • 视频元数据管理
  • 视频分镜看板位置管理

核心功能

1. 视频创建

支持多种视频生成方式:

  • img2video:图片转视频
  • text2video:文本转视频
  • keyframe:关键帧动画
  • fusion:视频融合
  • replace:视频替换
  • real:真实视频上传

2. 视频查询

  • 按项目查询视频列表
  • 按分镜查询视频
  • 按生成类型筛选
  • 按状态筛选
  • 支持分页

3. 视频状态管理

  • pending:等待处理
  • processing:处理中
  • completed:已完成
  • failed:失败

4. 视频分镜看板管理

  • 设置视频在分镜看板上的开始/结束时间
  • 时间范围查询
  • 时间冲突检测

数据库设计

videos 表

CREATE TABLE videos (
    id UUID PRIMARY KEY,
    project_id VARCHAR(36) NOT NULL,
    storyboard_id VARCHAR(36),
    name VARCHAR(255) NOT NULL,
    
    -- 视频生成类型 (使用 SMALLINT 存储)
    -- 1: img2video, 2: text2video, 3: keyframe, 4: fusion, 5: replace, 6: real
    type SMALLINT NOT NULL CHECK (type BETWEEN 1 AND 6),
    
    video_url VARCHAR(500),
    thumbnail_url VARCHAR(500),
    duration NUMERIC(10, 3),
    file_size BIGINT,
    width INTEGER,
    height INTEGER,
    frame_rate NUMERIC(5, 2),
    start_time NUMERIC(10, 3) DEFAULT 0,
    end_time NUMERIC(10, 3) NOT NULL,
    metadata JSONB DEFAULT '{}',
    
    -- 视频状态 (使用 SMALLINT 存储)
    -- 1: pending, 2: processing, 3: completed, 4: failed
    status SMALLINT NOT NULL DEFAULT 1 CHECK (status IN (1, 2, 3, 4)),
    
    ai_job_id BIGINT,
    error_message TEXT,
    created_by VARCHAR(36) NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

-- 索引
CREATE INDEX idx_videos_project_id ON videos(project_id);
CREATE INDEX idx_videos_storyboard_id ON videos(storyboard_id);
CREATE INDEX idx_videos_type ON videos(type);
CREATE INDEX idx_videos_status ON videos(status);
CREATE INDEX idx_videos_created_by ON videos(created_by);
CREATE INDEX idx_videos_time_range ON videos(project_id, start_time, end_time);
CREATE INDEX idx_videos_ai_job_id ON videos(ai_job_id) WHERE ai_job_id IS NOT NULL;

枚举值映射表

视频生成类型 (type)

名称 说明
1 img2video 图片转视频
2 text2video 文本转视频
3 keyframe 关键帧动画
4 fusion 视频融合
5 replace 视频替换
6 real 真实视频上传

视频状态 (status)

名称 说明
1 pending 等待处理
2 processing 处理中
3 completed 已完成
4 failed 失败

服务实现

VideoService 类

# app/services/video_service.py
from typing import List, Optional, Dict, Any
from sqlalchemy.orm import Session
from app.models.video import Video
from app.repositories.video_repository import VideoRepository
from app.repositories.project_repository import ProjectRepository
from app.repositories.storyboard_repository import StoryboardRepository
from app.schemas.video import VideoCreate, VideoUpdate, VideoFilter
from app.core.exceptions import NotFoundError, ValidationError, PermissionError
from app.services.ai_service import AIService
from app.services.storage_service import StorageService

class VideoService:
    def __init__(self, db: Session):
        self.repository = VideoRepository(db)
        self.project_repository = ProjectRepository(db)
        self.storyboard_repository = StoryboardRepository(db)
        self.ai_service = AIService(db)
        self.storage_service = StorageService()
        self.db = db

    async def get_videos(
        self,
        user_id: str,
        video_filter: VideoFilter,
        page: int = 1,
        page_size: int = 20
    ) -> Dict[str, Any]:
        """获取视频列表"""
        # 检查项目权限
        if video_filter.project_id:
            has_permission = await self.project_repository.check_user_permission(
                user_id, video_filter.project_id, 'viewer'
            )
            if not has_permission:
                raise PermissionError("没有权限访问此项目")

        videos = await self.repository.get_by_filter(
            project_id=video_filter.project_id,
            storyboard_id=video_filter.storyboard_id,
            video_type=video_filter.type,
            status=video_filter.status,
            page=page,
            page_size=page_size
        )

        total = await self.repository.count_by_filter(
            project_id=video_filter.project_id,
            storyboard_id=video_filter.storyboard_id,
            video_type=video_filter.type,
            status=video_filter.status
        )

        return {
            'items': videos,
            'total': total,
            'page': page,
            'page_size': page_size,
            'total_pages': (total + page_size - 1) // page_size
        }

    async def get_video(
        self,
        user_id: str,
        video_id: str
    ) -> Video:
        """获取视频详情"""
        video = await self.repository.get_by_id(video_id)
        if not video:
            raise NotFoundError("视频不存在")

        # 检查项目权限
        has_permission = await self.project_repository.check_user_permission(
            user_id, video.project_id, 'viewer'
        )
        if not has_permission:
            raise PermissionError("没有权限访问此视频")

        return video

    async def create_video_from_ai(
        self,
        user_id: str,
        project_id: str,
        video_data: VideoCreate
    ) -> Video:
        """通过 AI 生成视频"""
        # 检查项目权限
        has_permission = await self.project_repository.check_user_permission(
            user_id, project_id, 'editor'
        )
        if not has_permission:
            raise PermissionError("没有权限在此项目中创建视频")

        # 验证视频类型
        valid_types = ['img2video', 'text2video', 'keyframe', 'fusion', 'replace']
        if video_data.type not in valid_types:
            raise ValidationError("无效的视频生成类型")

        # 转换枚举值
        from app.models.video import VideoGenerationType, VideoStatus
        video_type_enum = VideoGenerationType.from_string(video_data.type)

        # 创建视频记录(状态为 pending)
        video = Video(
            project_id=project_id,
            storyboard_id=video_data.storyboard_id,
            name=video_data.name,
            type=video_type_enum,
            start_time=video_data.start_time or 0,
            end_time=video_data.end_time,
            metadata=video_data.metadata.dict() if video_data.metadata else {},
            status=VideoStatus.PENDING,
            created_by=user_id
        )

        created_video = await self.repository.create(video)

        # 提交 AI 生成任务
        ai_job = await self.ai_service.generate_video(
            user_id=user_id,
            video_type=video_data.type,
            prompt=video_data.metadata.get('prompt') if video_data.metadata else None,
            image_url=video_data.metadata.get('image_url') if video_data.metadata else None,
            duration=video_data.end_time - video_data.start_time,
            fps=video_data.metadata.get('fps', 30) if video_data.metadata else 30
        )

        # 更新视频记录,关联 AI 任务
        await self.repository.update(created_video.id, {
            'ai_job_id': ai_job['job_id'],
            'status': VideoStatus.PROCESSING
        })

        return created_video

    async def upload_video(
        self,
        user_id: str,
        project_id: str,
        video_data: VideoCreate,
        file_content: bytes,
        filename: str
    ) -> Video:
        """上传真实视频"""
        # 检查项目权限
        has_permission = await self.project_repository.check_user_permission(
            user_id, project_id, 'editor'
        )
        if not has_permission:
            raise PermissionError("没有权限在此项目中上传视频")

        # 上传文件到存储服务
        video_url = await self.storage_service.upload_file(
            file_content=file_content,
            filename=filename,
            folder='videos'
        )

        # 生成缩略图
        thumbnail_url = await self._generate_video_thumbnail(video_url)

        # 提取视频元数据
        file_size = len(file_content)
        width, height, duration, frame_rate = await self._extract_video_metadata(video_url)

        # 导入枚举类型
        from app.models.video import VideoGenerationType, VideoStatus

        # 创建视频记录
        video = Video(
            project_id=project_id,
            storyboard_id=video_data.storyboard_id,
            name=video_data.name,
            type=VideoGenerationType.REAL,
            video_url=video_url,
            thumbnail_url=thumbnail_url,
            file_size=file_size,
            width=width,
            height=height,
            duration=duration,
            frame_rate=frame_rate,
            start_time=video_data.start_time or 0,
            end_time=video_data.end_time or duration,
            status=VideoStatus.COMPLETED,
            created_by=user_id
        )

        return await self.repository.create(video)

    async def update_video(
        self,
        user_id: str,
        video_id: str,
        video_data: VideoUpdate
    ) -> Video:
        """更新视频"""
        video = await self.repository.get_by_id(video_id)
        if not video:
            raise NotFoundError("视频不存在")

        # 检查权限
        has_permission = await self.project_repository.check_user_permission(
            user_id, video.project_id, 'editor'
        )
        if not has_permission:
            raise PermissionError("没有权限编辑此视频")

        update_data = video_data.dict(exclude_unset=True)
        return await self.repository.update(video_id, update_data)

    async def delete_video(
        self,
        user_id: str,
        video_id: str
    ) -> None:
        """删除视频"""
        video = await self.repository.get_by_id(video_id)
        if not video:
            raise NotFoundError("视频不存在")

        # 检查权限
        has_permission = await self.project_repository.check_user_permission(
            user_id, video.project_id, 'editor'
        )
        if not has_permission:
            raise PermissionError("没有权限删除此视频")

        await self.repository.delete(video_id)

    async def update_video_status(
        self,
        video_id: str,
        status: str,
        video_url: Optional[str] = None,
        error_message: Optional[str] = None
    ) -> Video:
        """更新视频状态(由 AI 任务回调)"""
        update_data = {'status': status}

        if video_url:
            update_data['video_url'] = video_url
            # 生成缩略图
            thumbnail_url = await self._generate_video_thumbnail(video_url)
            update_data['thumbnail_url'] = thumbnail_url

            # 提取元数据
            width, height, duration, frame_rate = await self._extract_video_metadata(video_url)
            update_data.update({
                'width': width,
                'height': height,
                'duration': duration,
                'frame_rate': frame_rate
            })

        if error_message:
            update_data['error_message'] = error_message

        return await self.repository.update(video_id, update_data)

    async def get_videos_by_time_range(
        self,
        user_id: str,
        project_id: str,
        start_time: float,
        end_time: float
    ) -> List[Video]:
        """查询时间范围内的视频"""
        # 检查权限
        has_permission = await self.project_repository.check_user_permission(
            user_id, project_id, 'viewer'
        )
        if not has_permission:
            raise PermissionError("没有权限访问此项目")

        return await self.repository.get_by_time_range(
            project_id=project_id,
            start_time=start_time,
            end_time=end_time
        )

    async def _generate_video_thumbnail(self, video_url: str) -> str:
        """生成视频缩略图"""
        # 使用 FFmpeg 提取第一帧作为缩略图
        # 实际实现应调用视频处理服务
        return f"{video_url}_thumbnail.jpg"

    async def _extract_video_metadata(
        self,
        video_url: str
    ) -> tuple[int, int, float, float]:
        """提取视频元数据"""
        # 使用 FFmpeg 提取元数据
        # 实际实现应调用视频处理服务
        return 1920, 1080, 10.0, 30.0

API 接口

1. 获取视频列表

GET /api/v1/projects/{project_id}/videos

查询参数

  • storyboard_id: 分镜 ID(可选)
  • type: 视频类型(可选)
  • status: 状态(可选)
  • page: 页码(默认 1)
  • page_size: 每页数量(默认 20)

响应

{
  "items": [
    {
      "id": "video-123",
      "name": "开场视频",
      "type": "text2video",
      "video_url": "https://storage.jointo.ai/videos/123.mp4",
      "thumbnail_url": "https://storage.jointo.ai/thumbnails/123.jpg",
      "duration": 10.5,
      "status": "completed",
      "created_at": "2025-01-27T10:00:00Z"
    }
  ],
  "total": 50,
  "page": 1,
  "page_size": 20,
  "total_pages": 3
}

注意:API 响应中的 typestatus 字段返回字符串格式(如 "text2video", "completed"),但数据库中存储为 SMALLINT 类型(如 2, 3)。转换由 ORM 层的 to_string() 方法自动处理。

2. 创建 AI 生成视频

POST /api/v1/projects/{project_id}/videos/generate

请求体

{
  "name": "开场视频",
  "type": "text2video",
  "storyboard_id": "storyboard-123",
  "start_time": 0,
  "end_time": 10,
  "metadata": {
    "prompt": "一只猫咪在花园里奔跑",
    "fps": 30,
    "model": "runway"
  }
}

3. 上传视频

POST /api/v1/projects/{project_id}/videos/upload
Content-Type: multipart/form-data

请求体

name: "开场视频"
storyboard_id: "storyboard-123"
start_time: 0
end_time: 10
file: <binary>

4. 获取视频详情

GET /api/v1/videos/{video_id}

5. 更新视频

PUT /api/v1/videos/{video_id}

请求体

{
  "name": "开场视频(更新)",
  "start_time": 0,
  "end_time": 12
}

6. 删除视频

DELETE /api/v1/videos/{video_id}

7. 查询时间范围内的视频

GET /api/v1/projects/{project_id}/videos/time-range

查询参数

  • start_time: 开始时间(秒)
  • end_time: 结束时间(秒)

数据模型

Video 模型

# app/models/video.py
from sqlalchemy import Column, String, Text, BigInteger, Numeric, Integer, SmallInteger, DateTime, ForeignKey
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import relationship
from app.core.database import Base
import uuid
from datetime import datetime
from enum import IntEnum

class VideoGenerationType(IntEnum):
    """视频生成类型枚举"""
    IMG2VIDEO = 1    # 图片转视频
    TEXT2VIDEO = 2   # 文本转视频
    KEYFRAME = 3     # 关键帧动画
    FUSION = 4       # 视频融合
    REPLACE = 5      # 视频替换
    REAL = 6         # 真实视频上传
    
    @classmethod
    def from_string(cls, value: str) -> 'VideoGenerationType':
        """从字符串转换为枚举值"""
        mapping = {
            'img2video': cls.IMG2VIDEO,
            'text2video': cls.TEXT2VIDEO,
            'keyframe': cls.KEYFRAME,
            'fusion': cls.FUSION,
            'replace': cls.REPLACE,
            'real': cls.REAL
        }
        return mapping.get(value.lower())
    
    def to_string(self) -> str:
        """转换为字符串"""
        mapping = {
            self.IMG2VIDEO: 'img2video',
            self.TEXT2VIDEO: 'text2video',
            self.KEYFRAME: 'keyframe',
            self.FUSION: 'fusion',
            self.REPLACE: 'replace',
            self.REAL: 'real'
        }
        return mapping[self]

class VideoStatus(IntEnum):
    """视频状态枚举"""
    PENDING = 1      # 等待处理
    PROCESSING = 2   # 处理中
    COMPLETED = 3    # 已完成
    FAILED = 4       # 失败
    
    @classmethod
    def from_string(cls, value: str) -> 'VideoStatus':
        """从字符串转换为枚举值"""
        mapping = {
            'pending': cls.PENDING,
            'processing': cls.PROCESSING,
            'completed': cls.COMPLETED,
            'failed': cls.FAILED
        }
        return mapping.get(value.lower())
    
    def to_string(self) -> str:
        """转换为字符串"""
        mapping = {
            self.PENDING: 'pending',
            self.PROCESSING: 'processing',
            self.COMPLETED: 'completed',
            self.FAILED: 'failed'
        }
        return mapping[self]

class Video(Base):
    __tablename__ = "videos"

    id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
    project_id = Column(String, ForeignKey('projects.id'), nullable=False)
    storyboard_id = Column(String, ForeignKey('storyboards.id'))
    name = Column(String(255), nullable=False)
    type = Column(SmallInteger, nullable=False)
    video_url = Column(String(500))
    thumbnail_url = Column(String(500))
    duration = Column(Numeric(10, 3))
    file_size = Column(BigInteger)
    width = Column(Integer)
    height = Column(Integer)
    frame_rate = Column(Numeric(5, 2))
    start_time = Column(Numeric(10, 3), default=0)
    end_time = Column(Numeric(10, 3), nullable=False)
    metadata = Column(JSONB, default={})
    status = Column(SmallInteger, default=VideoStatus.PENDING)
    ai_job_id = Column(BigInteger)
    error_message = Column(Text)
    created_by = Column(String, ForeignKey('users.id'), nullable=False)
    created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
    updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))

    # 关系
    project = relationship("Project", back_populates="videos")
    storyboard = relationship("Storyboard", back_populates="videos")
    creator = relationship("User", back_populates="videos")

VideoCreate Schema

# app/schemas/video.py
from pydantic import BaseModel, Field
from typing import Optional, Dict, Any
from enum import Enum

class VideoGenerationType(str, Enum):
    IMG2VIDEO = 'img2video'
    TEXT2VIDEO = 'text2video'
    KEYFRAME = 'keyframe'
    FUSION = 'fusion'
    REPLACE = 'replace'
    REAL = 'real'

class VideoMetadata(BaseModel):
    prompt: Optional[str] = None
    image_url: Optional[str] = None
    fps: int = 30
    model: Optional[str] = None

class VideoCreate(BaseModel):
    name: str = Field(..., min_length=1, max_length=255)
    type: VideoGenerationType
    storyboard_id: Optional[str] = None
    start_time: Optional[float] = Field(0, ge=0)
    end_time: float = Field(..., gt=0)
    metadata: Optional[VideoMetadata] = None

class VideoUpdate(BaseModel):
    name: Optional[str] = Field(None, min_length=1, max_length=255)
    start_time: Optional[float] = Field(None, ge=0)
    end_time: Optional[float] = Field(None, gt=0)

class VideoFilter(BaseModel):
    project_id: Optional[str] = None
    storyboard_id: Optional[str] = None
    type: Optional[VideoGenerationType] = None
    status: Optional[str] = None

class VideoResponse(BaseModel):
    id: str
    project_id: str
    storyboard_id: Optional[str]
    name: str
    type: VideoGenerationType
    video_url: Optional[str]
    thumbnail_url: Optional[str]
    duration: Optional[float]
    width: Optional[int]
    height: Optional[int]
    start_time: float
    end_time: float
    status: str
    created_at: str
    updated_at: str

    class Config:
        from_attributes = True

相关文档


文档版本:v1.1
最后更新:2025-01-27

变更记录

v1.1 (2025-01-27)

  • 重构枚举字段实现方式:
    • type 字段从 PostgreSQL ENUM 改为 SMALLINT (1-6)
    • status 字段从 PostgreSQL ENUM 改为 SMALLINT (1-4)
    • Python 模型从 str, enum.Enum 改为 IntEnum
    • 添加 from_string()to_string() 转换方法
    • 添加枚举值映射表
    • 补充完整的数据库表结构定义
  • 原因:更好的性能、更容易扩展、与项目其他模块保持一致

v1.0 (2025-01-27)

  • 初始版本
  • 定义视频管理服务的核心功能和 API 接口