# 视频管理服务 > **文档版本**:v1.1 > **最后更新**:2025-01-27 --- ## 目录 1. [服务概述](#服务概述) 2. [核心功能](#核心功能) 3. [数据库设计](#数据库设计) 4. [服务实现](#服务实现) 5. [API 接口](#api-接口) 6. [数据模型](#数据模型) --- ## 服务概述 视频管理服务负责处理项目中的视频资源,包括 AI 生成视频、上传视频、视频编辑等功能。 ### 职责 - 视频 CRUD 操作 - 视频生成类型管理(文本转视频、图片转视频等) - 视频与分镜/项目关联 - 视频状态跟踪(pending/processing/completed/failed) - 视频元数据管理 - 视频分镜看板位置管理 --- ## 核心功能 ### 1. 视频创建 支持多种视频生成方式: - **img2video**:图片转视频 - **text2video**:文本转视频 - **keyframe**:关键帧动画 - **fusion**:视频融合 - **replace**:视频替换 - **real**:真实视频上传 ### 2. 视频查询 - 按项目查询视频列表 - 按分镜查询视频 - 按生成类型筛选 - 按状态筛选 - 支持分页 ### 3. 视频状态管理 - **pending**:等待处理 - **processing**:处理中 - **completed**:已完成 - **failed**:失败 ### 4. 视频分镜看板管理 - 设置视频在分镜看板上的开始/结束时间 - 时间范围查询 - 时间冲突检测 --- ## 数据库设计 ### videos 表 ```sql 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 类 ```python # 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) **响应**: ```json { "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 响应中的 `type` 和 `status` 字段返回字符串格式(如 "text2video", "completed"),但数据库中存储为 SMALLINT 类型(如 2, 3)。转换由 ORM 层的 `to_string()` 方法自动处理。 ### 2. 创建 AI 生成视频 ``` POST /api/v1/projects/{project_id}/videos/generate ``` **请求体**: ```json { "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: ``` ### 4. 获取视频详情 ``` GET /api/v1/videos/{video_id} ``` ### 5. 更新视频 ``` PUT /api/v1/videos/{video_id} ``` **请求体**: ```json { "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 模型 ```python # 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 ```python # 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 ``` --- ## 相关文档 - [AI 生成服务](./ai-service.md) - [分镜管理服务](./storyboard-service.md) - [分镜看板管理服务](./storyboard-board-service.md) - [异步任务处理](../07-async-tasks.md) - [数据库设计](../04-database-design.md) --- **文档版本**: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 接口