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
45 KiB
分镜资源服务
文档版本:v1.2
最后更新:2026-01-29
目录
服务概述
分镜资源服务(StoryboardResourceService)负责管理分镜的所有生成资源,包括分镜图片、视频、对白、配音等。这些资源是分镜的"输出产物",与项目素材(输入素材)区分开来。
职责
- 分镜图片管理(AI 生成的分镜画面)
- 分镜视频管理(AI 生成的视频)
- 对白管理(分镜的台词文本)
- 配音管理(对白的 TTS 音频)
- 版本管理(支持多版本生成和切换)
设计原则
- 分镜专属:所有资源归属单个分镜,不可跨分镜复用
- 版本管理:支持多版本生成,用户可选择激活版本
- 异步生成:支持 AI 异步生成,状态跟踪
- 生命周期绑定:资源随分镜删除而删除
与项目素材的区别
| 特性 | 分镜资源(storyboard_*) | 项目素材(project_resources) |
|---|---|---|
| 归属 | 归属单个分镜 | 归属项目,可被多个分镜使用 |
| 来源 | AI 生成(分镜的输出) | 用户上传或 AI 生成(输入) |
| 复用性 | 不可复用 | 可复用 |
| 版本管理 | 支持多版本 | 单一版本 |
| 生命周期 | 随分镜删除 | 独立于分镜 |
| 用途 | 分镜预览、视频生成 | 素材库、分镜制作 |
核心功能
1. 分镜图片管理
1.1 生成分镜图片
- AI 根据分镜描述生成图片
- 支持多次生成(产生多个版本)
- 用户选择激活版本
1.2 版本管理
- 每次生成创建新版本
- 保留历史版本
- 切换激活版本
1.3 状态跟踪
使用 ResourceStatus 枚举:
PENDING (0):等待生成PROCESSING (1):生成中COMPLETED (2):生成完成FAILED (3):生成失败
2. 分镜视频管理
2.1 生成分镜视频
- AI 根据分镜图片/描述生成视频
- 支持多次生成
- 版本管理
2.2 视频元数据
- 时长、分辨率、帧率
- 文件大小、格式
- AI 生成参数
3. 对白管理
3.1 对白列表
- 一个分镜可以有多条对白
- 区分角色
- 设置顺序和时间
3.2 对白编辑
- 添加/编辑/删除对白
- 调整顺序
- 设置时间点
4. 配音管理
4.1 生成配音
- 对白文本 → TTS → 音频
- 选择音色(男声/女声等)
- 调整参数(语速、音量、音调)
4.2 版本管理
- 一条对白可以有多个配音版本
- 不同音色、不同参数
- 切换激活版本
数据库设计
1. storyboard_images 表
CREATE TABLE storyboard_images (
image_id UUID PRIMARY KEY,
storyboard_id UUID NOT NULL,
-- 图片文件
url TEXT NOT NULL,
status SMALLINT NOT NULL, -- 0=pending, 1=processing, 2=completed, 3=failed
is_active BOOLEAN DEFAULT false, -- 当前使用的版本
version INTEGER NOT NULL DEFAULT 1,
-- 图片元数据
width INTEGER,
height INTEGER,
file_size BIGINT,
format TEXT, -- png/jpg/webp
checksum TEXT NOT NULL, -- SHA256 校验和,用于去重
-- 存储信息
storage_provider TEXT, -- minio/s3/oss
storage_path TEXT NOT NULL, -- 对象存储中的路径
-- AI 生成参数
ai_model TEXT,
ai_prompt TEXT, -- 生成提示词
ai_params JSONB DEFAULT '{}', -- 风格、种子等参数
-- 状态信息
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ,
CONSTRAINT storyboard_images_active_unique
UNIQUE (storyboard_id, is_active)
WHERE is_active = true
);
-- 索引
CREATE INDEX idx_storyboard_images_storyboard_id ON storyboard_images(storyboard_id);
CREATE INDEX idx_storyboard_images_status ON storyboard_images(status);
CREATE INDEX idx_storyboard_images_checksum ON storyboard_images(checksum);
设计说明:
- 版本管理:version 字段递增,支持多版本
- 激活版本:is_active 唯一约束,确保每个分镜只有一个激活图片
- 状态跟踪:status 使用 SMALLINT 类型(0=pending, 1=processing, 2=completed, 3=failed),代码中使用枚举
- AI 参数:ai_prompt 和 ai_params 记录生成参数,便于重新生成
- 文件去重:checksum 字段必填,通过 file_checksums 表实现全局去重
- 存储信息:storage_provider 和 storage_path 记录文件在对象存储中的位置
- 级联删除:分镜删除时自动删除所有图片
2. storyboard_videos 表
CREATE TABLE storyboard_videos (
video_id UUID PRIMARY KEY,
storyboard_id UUID NOT NULL,
-- 视频文件
url TEXT NOT NULL,
status SMALLINT NOT NULL, -- 0=pending, 1=processing, 2=completed, 3=failed
is_active BOOLEAN DEFAULT false,
version INTEGER NOT NULL DEFAULT 1,
-- 视频元数据
duration NUMERIC(10, 3),
resolution TEXT, -- 1920x1080
frame_rate INTEGER, -- 30
file_size BIGINT,
format TEXT, -- mp4/webm
checksum TEXT NOT NULL, -- SHA256 校验和,用于去重
-- 存储信息
storage_provider TEXT, -- minio/s3/oss
storage_path TEXT NOT NULL, -- 对象存储中的路径
-- AI 生成参数
ai_model TEXT,
ai_params JSONB DEFAULT '{}',
-- 状态信息
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
CONSTRAINT storyboard_videos_active_unique
UNIQUE (storyboard_id, is_active)
WHERE is_active = true
);
-- 索引
CREATE INDEX idx_storyboard_videos_storyboard_id ON storyboard_videos(storyboard_id);
CREATE INDEX idx_storyboard_videos_status ON storyboard_videos(status);
CREATE INDEX idx_storyboard_videos_checksum ON storyboard_videos(checksum);
设计说明:
- 视频元数据:duration、resolution、frame_rate 等完整信息
- 生成时间:started_at 和 completed_at 记录生成耗时
- 状态跟踪:status 使用 SMALLINT 类型(0=pending, 1=processing, 2=completed, 3=failed),代码中使用枚举
- 文件去重:checksum 字段必填,通过 file_checksums 表实现全局去重
- 存储信息:storage_provider 和 storage_path 记录文件在对象存储中的位置
- 其他设计:与 storyboard_images 类似
3. storyboard_dialogues 表
CREATE TABLE storyboard_dialogues (
dialogue_id UUID PRIMARY KEY,
storyboard_id UUID NOT NULL,
-- 对白内容
character_id UUID, -- 关联剧本角色(可选)
character_name TEXT, -- 角色名称(冗余,便于显示)
content TEXT NOT NULL, -- 对白内容
dialogue_type SMALLINT NOT NULL DEFAULT 1, -- 对白类型:1=normal(普通对白), 2=inner_monologue(内心OS), 3=narration(旁白)
-- 时间信息
sequence_order INTEGER NOT NULL, -- 在分镜中的顺序
start_time NUMERIC(10, 3), -- 在分镜中的开始时间(秒)
duration NUMERIC(10, 3), -- 时长
-- 元数据
emotion TEXT, -- 情绪(高兴/悲伤/愤怒等)
notes TEXT, -- 备注
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT storyboard_dialogues_order_unique
UNIQUE (storyboard_id, sequence_order)
);
-- 索引
CREATE INDEX idx_storyboard_dialogues_storyboard_id ON storyboard_dialogues(storyboard_id);
CREATE INDEX idx_storyboard_dialogues_character_id ON storyboard_dialogues(character_id) WHERE character_id IS NOT NULL;
CREATE INDEX idx_storyboard_dialogues_type ON storyboard_dialogues(dialogue_type) WHERE dialogue_type != 1;
-- 触发器
CREATE TRIGGER update_storyboard_dialogues_updated_at
BEFORE UPDATE ON storyboard_dialogues
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
设计说明:
- 角色关联:character_id 关联剧本角色,character_name 冗余存储
- 对白类型:dialogue_type 区分普通对白、内心OS、旁白三种类型
- 顺序管理:sequence_order 唯一约束,确保顺序不重复
- 时间信息:start_time 和 duration 用于分镜看板定位
- 情绪标记:emotion 字段用于 TTS 生成时的情感控制
对白类型说明:
| 类型值 | 字符串值 | 说明 | 使用场景 |
|---|---|---|---|
| 1 | normal | 普通对白 | 角色之间的正常对话 |
| 2 | inner_monologue | 内心OS | 角色的内心独白,其他角色听不到 |
| 3 | narration | 旁白 | 画外音、解说、回忆旁白等 |
4. storyboard_voiceovers 表
CREATE TABLE storyboard_voiceovers (
voiceover_id UUID PRIMARY KEY,
dialogue_id UUID NOT NULL,
storyboard_id UUID NOT NULL, -- 冗余,便于查询
-- 音频文件
audio_url TEXT NOT NULL,
status SMALLINT NOT NULL, -- 0=pending, 1=processing, 2=completed, 3=failed
is_active BOOLEAN DEFAULT false, -- 当前使用的版本
-- TTS 参数
voice_id TEXT NOT NULL, -- TTS 服务的音色 ID
voice_name TEXT, -- 音色名称(如"温柔女声")
speed NUMERIC(3, 2) DEFAULT 1.0, -- 语速(0.5-2.0)
volume NUMERIC(3, 2) DEFAULT 1.0, -- 音量(0.0-1.0)
pitch NUMERIC(3, 2) DEFAULT 1.0, -- 音调(0.5-2.0)
-- 音频元数据
duration NUMERIC(10, 3),
file_size BIGINT,
format TEXT, -- mp3/wav
checksum TEXT NOT NULL, -- SHA256 校验和,用于去重
-- 存储信息
storage_provider TEXT, -- minio/s3/oss
storage_path TEXT NOT NULL, -- 对象存储中的路径
-- 状态信息
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ,
CONSTRAINT storyboard_voiceovers_active_unique
UNIQUE (dialogue_id, is_active)
WHERE is_active = true
);
-- 索引
CREATE INDEX idx_storyboard_voiceovers_dialogue_id ON storyboard_voiceovers(dialogue_id);
CREATE INDEX idx_storyboard_voiceovers_storyboard_id ON storyboard_voiceovers(storyboard_id);
CREATE INDEX idx_storyboard_voiceovers_status ON storyboard_voiceovers(status);
CREATE INDEX idx_storyboard_voiceovers_checksum ON storyboard_voiceovers(checksum);
设计说明:
- 双重关联:关联 dialogue_id 和 storyboard_id(冗余,便于查询)
- TTS 参数:voice_id、speed、volume、pitch 完整记录
- 激活版本:每条对白只能有一个激活配音
- 状态跟踪:status 使用 SMALLINT 类型(0=pending, 1=processing, 2=completed, 3=failed),代码中使用枚举
- 音频元数据:duration、file_size、format 等信息
- 文件去重:checksum 字段必填,通过 file_checksums 表实现全局去重
- 存储信息:storage_provider 和 storage_path 记录文件在对象存储中的位置
服务实现
状态枚举定义
# app/models/storyboard_resource.py
from enum import IntEnum
class ResourceStatus(IntEnum):
"""资源生成状态"""
PENDING = 0 # 等待生成
PROCESSING = 1 # 生成中
COMPLETED = 2 # 生成完成
FAILED = 3 # 生成失败
StoryboardResourceService 类
# app/services/storyboard_resource_service.py
import logging
from typing import List, Optional, Dict, Any
from uuid import UUID
from decimal import Decimal
from sqlalchemy.orm import Session
from app.models.storyboard_resource import (
StoryboardImage, StoryboardVideo,
StoryboardDialogue, StoryboardVoiceover,
ResourceStatus
)
from app.repositories.storyboard_resource_repository import StoryboardResourceRepository
from app.repositories.storyboard_repository import StoryboardRepository
from app.services.file_storage_service import FileStorageService
from app.schemas.storyboard_resource import (
ImageCreate, VideoCreate,
DialogueCreate, DialogueUpdate,
VoiceoverCreate
)
from app.core.exceptions import NotFoundError, ValidationError, PermissionError
logger = logging.getLogger(__name__)
class StoryboardResourceService:
def __init__(self, db: Session):
self.repository = StoryboardResourceRepository(db)
self.storyboard_repo = StoryboardRepository(db)
self.file_storage = FileStorageService(db)
self.db = db
# ==================== 分镜图片管理 ====================
async def create_image(
self,
user_id: UUID,
storyboard_id: UUID,
image_data: ImageCreate
) -> StoryboardImage:
"""创建分镜图片(AI 生成后调用)"""
logger.info(
"用户 %s 为分镜 %s 创建图片",
user_id,
storyboard_id
)
try:
# 检查分镜权限
storyboard = await self._check_storyboard_permission(
user_id, storyboard_id, 'editor'
)
# 获取下一个版本号
max_version = await self.repository.get_max_image_version(storyboard_id)
version = (max_version or 0) + 1
# 检查文件是否已存在(通过 checksum)
existing_file = await self.file_storage.get_by_checksum(image_data.checksum)
if existing_file:
# 文件已存在,增加引用计数
await self.file_storage.increase_reference_count(image_data.checksum)
file_url = existing_file.file_url
storage_provider = existing_file.storage_provider
storage_path = existing_file.storage_path
logger.debug(
"文件已存在,复用: checksum=%s",
image_data.checksum
)
else:
# 文件不存在,使用传入的 URL(AI 服务已上传)
file_url = image_data.url
storage_provider = image_data.storage_provider or 'minio'
storage_path = image_data.storage_path
image = StoryboardImage(
storyboard_id=storyboard_id,
url=file_url,
status=image_data.status,
is_active=image_data.is_active if image_data.is_active is not None else False,
version=version,
width=image_data.width,
height=image_data.height,
file_size=image_data.file_size,
format=image_data.format,
checksum=image_data.checksum,
storage_provider=storage_provider,
storage_path=storage_path,
ai_model=image_data.ai_model,
ai_prompt=image_data.ai_prompt,
ai_params=image_data.ai_params or {}
)
created_image = await self.repository.create_image(image)
# 如果设置为激活,更新分镜的 thumbnail_url
if image_data.is_active:
await self.storyboard_repo.update(storyboard_id, {
'thumbnail_url': file_url,
'thumbnail_id': created_image.image_id
})
logger.info(
"图片创建成功: image_id=%s, version=%d",
created_image.image_id,
version
)
return created_image
except Exception as e:
logger.error(
"创建图片失败: user_id=%s, storyboard_id=%s",
user_id,
storyboard_id,
exc_info=True
)
raise
async def get_images(
self,
user_id: UUID,
storyboard_id: UUID
) -> List[StoryboardImage]:
"""获取分镜的所有图片版本"""
await self._check_storyboard_permission(user_id, storyboard_id, 'viewer')
return await self.repository.get_images_by_storyboard(storyboard_id)
async def get_active_image(
self,
user_id: UUID,
storyboard_id: UUID
) -> Optional[StoryboardImage]:
"""获取分镜的激活图片"""
await self._check_storyboard_permission(user_id, storyboard_id, 'viewer')
return await self.repository.get_active_image(storyboard_id)
async def set_active_image(
self,
user_id: UUID,
image_id: UUID
) -> StoryboardImage:
"""设置激活图片"""
image = await self.repository.get_image_by_id(image_id)
if not image:
raise NotFoundError("图片不存在")
await self._check_storyboard_permission(
user_id, image.storyboard_id, 'editor'
)
# 取消当前激活的图片
await self.repository.deactivate_all_images(image.storyboard_id)
# 激活新图片
image.is_active = True
updated_image = await self.repository.update_image(image)
# 更新分镜的 thumbnail_url
await self.storyboard_repo.update(image.storyboard_id, {
'thumbnail_url': image.url,
'thumbnail_id': image.image_id
})
return updated_image
async def delete_image(
self,
user_id: UUID,
image_id: UUID
) -> None:
"""删除分镜图片"""
image = await self.repository.get_image_by_id(image_id)
if not image:
raise NotFoundError("图片不存在")
await self._check_storyboard_permission(
user_id, image.storyboard_id, 'editor'
)
# 不允许删除激活的图片
if image.is_active:
raise ValidationError("不能删除激活的图片,请先切换到其他版本")
# 删除数据库记录
await self.repository.delete_image(image_id)
# 减少文件引用计数(如果引用计数为 0,FileStorageService 会自动删除文件)
await self.file_storage.decrease_reference_count(image.checksum)
# ==================== 分镜视频管理 ====================
async def create_video(
self,
user_id: UUID,
storyboard_id: UUID,
video_data: VideoCreate
) -> StoryboardVideo:
"""创建分镜视频(AI 生成后调用)"""
await self._check_storyboard_permission(user_id, storyboard_id, 'editor')
# 获取下一个版本号
max_version = await self.repository.get_max_video_version(storyboard_id)
version = (max_version or 0) + 1
# 检查文件是否已存在(通过 checksum)
existing_file = await self.file_storage.get_by_checksum(video_data.checksum)
if existing_file:
# 文件已存在,增加引用计数
await self.file_storage.increase_reference_count(video_data.checksum)
file_url = existing_file.file_url
storage_provider = existing_file.storage_provider
storage_path = existing_file.storage_path
else:
# 文件不存在,使用传入的 URL(AI 服务已上传)
file_url = video_data.url
storage_provider = video_data.storage_provider or 'minio'
storage_path = video_data.storage_path
video = StoryboardVideo(
storyboard_id=storyboard_id,
url=file_url,
status=video_data.status,
is_active=video_data.is_active if video_data.is_active is not None else False,
version=version,
duration=video_data.duration,
resolution=video_data.resolution,
frame_rate=video_data.frame_rate,
file_size=video_data.file_size,
format=video_data.format,
checksum=video_data.checksum,
storage_provider=storage_provider,
storage_path=storage_path,
ai_model=video_data.ai_model,
ai_params=video_data.ai_params or {}
)
return await self.repository.create_video(video)
async def get_videos(
self,
user_id: UUID,
storyboard_id: UUID
) -> List[StoryboardVideo]:
"""获取分镜的所有视频版本"""
await self._check_storyboard_permission(user_id, storyboard_id, 'viewer')
return await self.repository.get_videos_by_storyboard(storyboard_id)
async def get_active_video(
self,
user_id: UUID,
storyboard_id: UUID
) -> Optional[StoryboardVideo]:
"""获取分镜的激活视频"""
await self._check_storyboard_permission(user_id, storyboard_id, 'viewer')
return await self.repository.get_active_video(storyboard_id)
async def set_active_video(
self,
user_id: UUID,
video_id: UUID
) -> StoryboardVideo:
"""设置激活视频"""
video = await self.repository.get_video_by_id(video_id)
if not video:
raise NotFoundError("视频不存在")
await self._check_storyboard_permission(
user_id, video.storyboard_id, 'editor'
)
# 取消当前激活的视频
await self.repository.deactivate_all_videos(video.storyboard_id)
# 激活新视频
video.is_active = True
return await self.repository.update_video(video)
# ==================== 对白管理 ====================
async def create_dialogue(
self,
user_id: UUID,
storyboard_id: UUID,
dialogue_data: DialogueCreate
) -> StoryboardDialogue:
"""创建对白"""
await self._check_storyboard_permission(user_id, storyboard_id, 'editor')
# 如果未指定顺序,自动分配
if dialogue_data.sequence_order is None:
max_order = await self.repository.get_max_dialogue_order(storyboard_id)
sequence_order = (max_order or 0) + 1
else:
sequence_order = dialogue_data.sequence_order
dialogue = StoryboardDialogue(
storyboard_id=storyboard_id,
character_id=dialogue_data.character_id,
character_name=dialogue_data.character_name,
content=dialogue_data.content,
sequence_order=sequence_order,
start_time=dialogue_data.start_time,
duration=dialogue_data.duration,
emotion=dialogue_data.emotion,
notes=dialogue_data.notes
)
return await self.repository.create_dialogue(dialogue)
async def get_dialogues(
self,
user_id: UUID,
storyboard_id: UUID
) -> List[StoryboardDialogue]:
"""获取分镜的所有对白"""
await self._check_storyboard_permission(user_id, storyboard_id, 'viewer')
return await self.repository.get_dialogues_by_storyboard(storyboard_id)
async def update_dialogue(
self,
user_id: UUID,
dialogue_id: UUID,
dialogue_data: DialogueUpdate
) -> StoryboardDialogue:
"""更新对白"""
dialogue = await self.repository.get_dialogue_by_id(dialogue_id)
if not dialogue:
raise NotFoundError("对白不存在")
await self._check_storyboard_permission(
user_id, dialogue.storyboard_id, 'editor'
)
update_data = dialogue_data.dict(exclude_unset=True)
return await self.repository.update_dialogue(dialogue_id, update_data)
async def delete_dialogue(
self,
user_id: UUID,
dialogue_id: UUID
) -> None:
"""删除对白"""
dialogue = await self.repository.get_dialogue_by_id(dialogue_id)
if not dialogue:
raise NotFoundError("对白不存在")
await self._check_storyboard_permission(
user_id, dialogue.storyboard_id, 'editor'
)
await self.repository.delete_dialogue(dialogue_id)
async def reorder_dialogues(
self,
user_id: UUID,
storyboard_id: UUID,
dialogue_orders: Dict[UUID, int]
) -> List[StoryboardDialogue]:
"""重新排序对白"""
await self._check_storyboard_permission(user_id, storyboard_id, 'editor')
# 批量更新顺序
for dialogue_id, new_order in dialogue_orders.items():
await self.repository.update_dialogue(dialogue_id, {
'sequence_order': new_order
})
return await self.repository.get_dialogues_by_storyboard(storyboard_id)
# ==================== 配音管理 ====================
async def create_voiceover(
self,
user_id: UUID,
dialogue_id: UUID,
voiceover_data: VoiceoverCreate
) -> StoryboardVoiceover:
"""创建配音(TTS 生成后调用)"""
dialogue = await self.repository.get_dialogue_by_id(dialogue_id)
if not dialogue:
raise NotFoundError("对白不存在")
await self._check_storyboard_permission(
user_id, dialogue.storyboard_id, 'editor'
)
# 检查文件是否已存在(通过 checksum)
existing_file = await self.file_storage.get_by_checksum(voiceover_data.checksum)
if existing_file:
# 文件已存在,增加引用计数
await self.file_storage.increase_reference_count(voiceover_data.checksum)
audio_url = existing_file.file_url
storage_provider = existing_file.storage_provider
storage_path = existing_file.storage_path
else:
# 文件不存在,使用传入的 URL(TTS 服务已上传)
audio_url = voiceover_data.audio_url
storage_provider = voiceover_data.storage_provider or 'minio'
storage_path = voiceover_data.storage_path
voiceover = StoryboardVoiceover(
dialogue_id=dialogue_id,
storyboard_id=dialogue.storyboard_id,
audio_url=audio_url,
status=voiceover_data.status,
is_active=voiceover_data.is_active if voiceover_data.is_active is not None else False,
voice_id=voiceover_data.voice_id,
voice_name=voiceover_data.voice_name,
speed=voiceover_data.speed,
volume=voiceover_data.volume,
pitch=voiceover_data.pitch,
duration=voiceover_data.duration,
file_size=voiceover_data.file_size,
format=voiceover_data.format,
checksum=voiceover_data.checksum,
storage_provider=storage_provider,
storage_path=storage_path
)
return await self.repository.create_voiceover(voiceover)
async def get_voiceovers(
self,
user_id: UUID,
dialogue_id: UUID
) -> List[StoryboardVoiceover]:
"""获取对白的所有配音版本"""
dialogue = await self.repository.get_dialogue_by_id(dialogue_id)
if not dialogue:
raise NotFoundError("对白不存在")
await self._check_storyboard_permission(
user_id, dialogue.storyboard_id, 'viewer'
)
return await self.repository.get_voiceovers_by_dialogue(dialogue_id)
async def get_active_voiceover(
self,
user_id: UUID,
dialogue_id: UUID
) -> Optional[StoryboardVoiceover]:
"""获取对白的激活配音"""
dialogue = await self.repository.get_dialogue_by_id(dialogue_id)
if not dialogue:
raise NotFoundError("对白不存在")
await self._check_storyboard_permission(
user_id, dialogue.storyboard_id, 'viewer'
)
return await self.repository.get_active_voiceover(dialogue_id)
async def set_active_voiceover(
self,
user_id: UUID,
voiceover_id: UUID
) -> StoryboardVoiceover:
"""设置激活配音"""
voiceover = await self.repository.get_voiceover_by_id(voiceover_id)
if not voiceover:
raise NotFoundError("配音不存在")
dialogue = await self.repository.get_dialogue_by_id(voiceover.dialogue_id)
await self._check_storyboard_permission(
user_id, dialogue.storyboard_id, 'editor'
)
# 取消当前激活的配音
await self.repository.deactivate_all_voiceovers(voiceover.dialogue_id)
# 激活新配音
voiceover.is_active = True
return await self.repository.update_voiceover(voiceover)
# ==================== 辅助方法 ====================
async def _check_storyboard_permission(
self,
user_id: UUID,
storyboard_id: UUID,
required_role: str = 'viewer'
) -> Any:
"""检查分镜权限"""
storyboard = await self.storyboard_repo.get_by_id(storyboard_id)
if not storyboard:
raise NotFoundError("分镜不存在")
from app.repositories.project_repository import ProjectRepository
project_repo = ProjectRepository(self.db)
has_permission = await project_repo.check_user_permission(
user_id, storyboard.project_id, required_role
)
if not has_permission:
raise PermissionError("没有权限访问此分镜")
return storyboard
API 接口
1. 分镜图片 API
1.1 获取分镜的所有图片版本
GET /api/v1/storyboards/{storyboard_id}/images
响应:
{
"success": true,
"code": 200,
"message": "Success",
"data": [
{
"image_id": "019d1234-5678-7abc-def0-111111111111",
"storyboard_id": "019d1234-5678-7abc-def0-222222222222",
"url": "https://storage.example.com/images/abc123.png",
"status": 2,
"is_active": true,
"version": 2,
"width": 1920,
"height": 1080,
"file_size": 2048000,
"format": "png",
"ai_model": "stable-diffusion-xl",
"ai_prompt": "一个年轻人在咖啡厅里...",
"created_at": "2026-01-22T10:00:00Z",
"completed_at": "2026-01-22T10:02:30Z"
}
],
"timestamp": "2026-02-03T10:00:00Z"
}
1.2 获取激活的图片
GET /api/v1/storyboards/{storyboard_id}/images/active
1.3 设置激活图片
POST /api/v1/storyboard-images/{image_id}/activate
1.4 删除图片
DELETE /api/v1/storyboard-images/{image_id}
2. 分镜视频 API
2.1 获取分镜的所有视频版本
GET /api/v1/storyboards/{storyboard_id}/videos
2.2 获取激活的视频
GET /api/v1/storyboards/{storyboard_id}/videos/active
2.3 设置激活视频
POST /api/v1/storyboard-videos/{video_id}/activate
3. 对白 API
3.1 获取分镜的所有对白
GET /api/v1/storyboards/{storyboard_id}/dialogues
响应:
{
"success": true,
"code": 200,
"message": "Success",
"data": [
{
"dialogue_id": "019d1234-5678-7abc-def0-333333333333",
"storyboard_id": "019d1234-5678-7abc-def0-222222222222",
"character_id": "019d1234-5678-7abc-def0-444444444444",
"character_name": "张三",
"content": "你好,最近怎么样?",
"sequence_order": 1,
"start_time": 0.5,
"duration": 2.5,
"emotion": "friendly",
"created_at": "2026-01-22T10:00:00Z"
}
],
"timestamp": "2026-02-03T10:00:00Z"
}
3.2 创建对白
POST /api/v1/storyboards/{storyboard_id}/dialogues
请求体:
{
"character_id": "019d1234-5678-7abc-def0-444444444444",
"character_name": "张三",
"content": "你好,最近怎么样?",
"sequence_order": 1,
"start_time": 0.5,
"duration": 2.5,
"emotion": "friendly"
}
3.3 更新对白
PATCH /api/v1/dialogues/{dialogue_id}
3.4 删除对白
DELETE /api/v1/dialogues/{dialogue_id}
3.5 重新排序对白
POST /api/v1/storyboards/{storyboard_id}/dialogues/reorder
请求体:
{
"dialogue_orders": {
"019d1234-5678-7abc-def0-333333333333": 1,
"019d1234-5678-7abc-def0-555555555555": 2
}
}
4. 配音 API
4.1 获取对白的所有配音版本
GET /api/v1/dialogues/{dialogue_id}/voiceovers
响应:
{
"success": true,
"code": 200,
"message": "Success",
"data": [
{
"voiceover_id": "019d1234-5678-7abc-def0-666666666666",
"dialogue_id": "019d1234-5678-7abc-def0-333333333333",
"audio_url": "https://storage.example.com/audio/voice123.mp3",
"status": 2,
"is_active": true,
"voice_id": "zh-CN-XiaoxiaoNeural",
"voice_name": "晓晓(温柔女声)",
"speed": 1.0,
"volume": 1.0,
"pitch": 1.0,
"duration": 2.5,
"file_size": 40960,
"format": "mp3",
"created_at": "2026-01-22T10:05:00Z"
}
],
"timestamp": "2026-02-03T10:00:00Z"
}
4.2 获取激活的配音
GET /api/v1/dialogues/{dialogue_id}/voiceovers/active
4.3 设置激活配音
POST /api/v1/voiceovers/{voiceover_id}/activate
文件存储集成
与 FileStorageService 的集成
分镜资源服务通过 FileStorageService 实现文件去重和引用计数管理:
1. 文件上传流程
# AI 服务生成文件后调用
async def create_image(self, user_id, storyboard_id, image_data):
# 1. 检查文件是否已存在(通过 checksum)
existing_file = await self.file_storage.get_by_checksum(image_data.checksum)
if existing_file:
# 2a. 文件已存在,增加引用计数
await self.file_storage.increase_reference_count(image_data.checksum)
file_url = existing_file.file_url
else:
# 2b. 文件不存在,使用 AI 服务已上传的 URL
file_url = image_data.url
# 3. 创建分镜图片记录
image = StoryboardImage(
url=file_url,
checksum=image_data.checksum,
storage_provider=image_data.storage_provider,
storage_path=image_data.storage_path,
# ...
)
return await self.repository.create_image(image)
2. 文件删除流程
async def delete_image(self, user_id, image_id):
image = await self.repository.get_image_by_id(image_id)
# 1. 删除数据库记录
await self.repository.delete_image(image_id)
# 2. 减少文件引用计数
# 如果引用计数为 0,FileStorageService 会自动删除文件
await self.file_storage.decrease_reference_count(image.checksum)
3. 去重机制
- 所有分镜资源(图片、视频、配音)都通过
checksum字段关联file_checksums表 - 相同文件只存储一次,节省存储空间
- 引用计数管理确保文件安全删除
4. 存储信息
分镜资源表直接存储文件信息:
url:文件访问 URLchecksum:SHA256 校验和(必填)storage_provider:存储提供商(minio/s3/oss)storage_path:对象存储中的路径
优势:
- 不依赖
attachments表,语义更清晰 - 通过
checksum关联file_checksums表实现去重 - 支持跨表去重(与
attachments、project_resources等共享去重机制)
数据库迁移脚本
Alembic 迁移示例
"""添加分镜资源表
Revision ID: 20260129_1800_add_storyboard_resources
Revises: <previous_revision>
Create Date: 2026-01-29 18:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '20260129_1800_add_storyboard_resources'
down_revision = '<previous_revision>'
branch_labels = None
depends_on = None
def upgrade() -> None:
# 1. 创建 storyboard_images 表
op.create_table(
'storyboard_images',
sa.Column('image_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('storyboard_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('url', sa.Text(), nullable=False),
sa.Column('status', sa.SmallInteger(), nullable=False),
sa.Column('is_active', sa.Boolean(), server_default='false'),
sa.Column('version', sa.Integer(), nullable=False, server_default='1'),
sa.Column('width', sa.Integer(), nullable=True),
sa.Column('height', sa.Integer(), nullable=True),
sa.Column('file_size', sa.BigInteger(), nullable=True),
sa.Column('format', sa.Text(), nullable=True),
sa.Column('checksum', sa.Text(), nullable=False),
sa.Column('storage_provider', sa.Text(), nullable=True),
sa.Column('storage_path', sa.Text(), nullable=False),
sa.Column('ai_model', sa.Text(), nullable=True),
sa.Column('ai_prompt', sa.Text(), nullable=True),
sa.Column('ai_params', postgresql.JSONB(astext_type=sa.Text()), server_default='{}'),
sa.Column('error_message', sa.Text(), nullable=True),
sa.Column('created_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('completed_at', sa.TIMESTAMP(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('image_id'),
sa.UniqueConstraint('storyboard_id', 'is_active', name='storyboard_images_active_unique', postgresql_where=sa.text('is_active = true'))
)
# 表注释
op.execute("COMMENT ON TABLE storyboard_images IS '分镜图片表 - 存储AI生成的分镜画面,支持多版本管理'")
# 列注释
op.execute("COMMENT ON COLUMN storyboard_images.image_id IS '图片ID(主键)'")
op.execute("COMMENT ON COLUMN storyboard_images.storyboard_id IS '所属分镜ID'")
op.execute("COMMENT ON COLUMN storyboard_images.status IS '生成状态: 0=pending, 1=processing, 2=completed, 3=failed'")
op.execute("COMMENT ON COLUMN storyboard_images.is_active IS '是否为当前激活版本(每个分镜只能有一个激活图片)'")
op.execute("COMMENT ON COLUMN storyboard_images.checksum IS 'SHA256校验和,用于文件去重'")
# 索引
op.create_index('idx_storyboard_images_storyboard_id', 'storyboard_images', ['storyboard_id'])
op.create_index('idx_storyboard_images_status', 'storyboard_images', ['status'])
op.create_index('idx_storyboard_images_checksum', 'storyboard_images', ['checksum'])
# 2. 创建 storyboard_videos 表
op.create_table(
'storyboard_videos',
sa.Column('video_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('storyboard_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('url', sa.Text(), nullable=False),
sa.Column('status', sa.SmallInteger(), nullable=False),
sa.Column('is_active', sa.Boolean(), server_default='false'),
sa.Column('version', sa.Integer(), nullable=False, server_default='1'),
sa.Column('duration', sa.Numeric(10, 3), nullable=True),
sa.Column('resolution', sa.Text(), nullable=True),
sa.Column('frame_rate', sa.Integer(), nullable=True),
sa.Column('file_size', sa.BigInteger(), nullable=True),
sa.Column('format', sa.Text(), nullable=True),
sa.Column('checksum', sa.Text(), nullable=False),
sa.Column('storage_provider', sa.Text(), nullable=True),
sa.Column('storage_path', sa.Text(), nullable=False),
sa.Column('ai_model', sa.Text(), nullable=True),
sa.Column('ai_params', postgresql.JSONB(astext_type=sa.Text()), server_default='{}'),
sa.Column('error_message', sa.Text(), nullable=True),
sa.Column('created_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('started_at', sa.TIMESTAMP(timezone=True), nullable=True),
sa.Column('completed_at', sa.TIMESTAMP(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('video_id'),
sa.UniqueConstraint('storyboard_id', 'is_active', name='storyboard_videos_active_unique', postgresql_where=sa.text('is_active = true'))
)
op.execute("COMMENT ON TABLE storyboard_videos IS '分镜视频表 - 存储AI生成的分镜视频,支持多版本管理'")
op.create_index('idx_storyboard_videos_storyboard_id', 'storyboard_videos', ['storyboard_id'])
op.create_index('idx_storyboard_videos_status', 'storyboard_videos', ['status'])
op.create_index('idx_storyboard_videos_checksum', 'storyboard_videos', ['checksum'])
# 3. 创建 storyboard_dialogues 表
op.create_table(
'storyboard_dialogues',
sa.Column('dialogue_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('storyboard_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('character_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('character_name', sa.Text(), nullable=True),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('sequence_order', sa.Integer(), nullable=False),
sa.Column('start_time', sa.Numeric(10, 3), nullable=True),
sa.Column('duration', sa.Numeric(10, 3), nullable=True),
sa.Column('emotion', sa.Text(), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('created_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('dialogue_id'),
sa.UniqueConstraint('storyboard_id', 'sequence_order', name='storyboard_dialogues_order_unique')
)
op.execute("COMMENT ON TABLE storyboard_dialogues IS '分镜对白表 - 存储分镜的台词文本'")
op.create_index('idx_storyboard_dialogues_storyboard_id', 'storyboard_dialogues', ['storyboard_id'])
op.create_index('idx_storyboard_dialogues_character_id', 'storyboard_dialogues', ['character_id'], postgresql_where=sa.text('character_id IS NOT NULL'))
# 4. 创建 storyboard_voiceovers 表
op.create_table(
'storyboard_voiceovers',
sa.Column('voiceover_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('dialogue_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('storyboard_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('audio_url', sa.Text(), nullable=False),
sa.Column('status', sa.SmallInteger(), nullable=False),
sa.Column('is_active', sa.Boolean(), server_default='false'),
sa.Column('voice_id', sa.Text(), nullable=False),
sa.Column('voice_name', sa.Text(), nullable=True),
sa.Column('speed', sa.Numeric(3, 2), server_default='1.0'),
sa.Column('volume', sa.Numeric(3, 2), server_default='1.0'),
sa.Column('pitch', sa.Numeric(3, 2), server_default='1.0'),
sa.Column('duration', sa.Numeric(10, 3), nullable=True),
sa.Column('file_size', sa.BigInteger(), nullable=True),
sa.Column('format', sa.Text(), nullable=True),
sa.Column('checksum', sa.Text(), nullable=False),
sa.Column('storage_provider', sa.Text(), nullable=True),
sa.Column('storage_path', sa.Text(), nullable=False),
sa.Column('error_message', sa.Text(), nullable=True),
sa.Column('created_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('completed_at', sa.TIMESTAMP(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('voiceover_id'),
sa.UniqueConstraint('dialogue_id', 'is_active', name='storyboard_voiceovers_active_unique', postgresql_where=sa.text('is_active = true'))
)
op.execute("COMMENT ON TABLE storyboard_voiceovers IS '分镜配音表 - 存储对白的TTS音频,支持多版本管理'")
op.create_index('idx_storyboard_voiceovers_dialogue_id', 'storyboard_voiceovers', ['dialogue_id'])
op.create_index('idx_storyboard_voiceovers_storyboard_id', 'storyboard_voiceovers', ['storyboard_id'])
op.create_index('idx_storyboard_voiceovers_status', 'storyboard_voiceovers', ['status'])
op.create_index('idx_storyboard_voiceovers_checksum', 'storyboard_voiceovers', ['checksum'])
def downgrade() -> None:
op.drop_table('storyboard_voiceovers')
op.drop_table('storyboard_dialogues')
op.drop_table('storyboard_videos')
op.drop_table('storyboard_images')
相关文档
变更记录
v1.3 (2026-02-03)
- ✅ 修复 UUID v7 生成方式(移除数据库 DEFAULT,改为应用层生成)
- ✅ 修复日志格式化(统一使用 %-formatting)
- ✅ 修复异常日志(添加 exc_info=True)
- ✅ 修复 API 响应格式(统一使用 ApiResponse 格式)
- ✅ 移除数据库注释(将在迁移脚本中添加)
- ✅ 符合 jointo-tech-stack v1.0 规范
v1.2 (2026-01-29)
- ✅ 添加完整的日志记录(符合 logging.md 规范)
- ✅ 添加数据库表和列注释
- ✅ 统一 API 响应格式(符合 api-design.md 规范)
- ✅ 添加 Alembic 迁移脚本示例
- ✅ 添加异常处理和日志记录
- ✅ 统一文档版本号
v1.1 (2026-01-22)
- 集成 FileStorageService 实现文件去重
- 补充文件存储实现细节
- 更新数据库设计:
- checksum 改为必填
- 新增 storage_provider 和 storage_path
- status 字段改为 SMALLINT 类型(使用代码枚举)
- 更新服务实现(文件上传和删除逻辑)
- 新增"文件存储集成"章节
- 新增 ResourceStatus 枚举定义
v1.0 (2026-01-22)
- 初始版本
- 定义分镜资源服务架构
- 实现分镜图片、视频、对白、配音管理
- 支持版本管理和激活切换
- 与项目素材服务区分
文档版本:v1.2
最后更新:2026-01-29