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
21 KiB
视频管理服务
文档版本:v1.1
最后更新:2025-01-27
目录
服务概述
视频管理服务负责处理项目中的视频资源,包括 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 响应中的
type和status字段返回字符串格式(如 "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 接口