# 分镜资源服务 > **文档版本**:v1.2 > **最后更新**:2026-01-29 --- ## 目录 1. [服务概述](#服务概述) 2. [核心功能](#核心功能) 3. [数据库设计](#数据库设计) 4. [服务实现](#服务实现) 5. [API 接口](#api-接口) --- ## 服务概述 分镜资源服务(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 表 ```sql 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); ``` **设计说明**: 1. **版本管理**:version 字段递增,支持多版本 2. **激活版本**:is_active 唯一约束,确保每个分镜只有一个激活图片 3. **状态跟踪**:status 使用 SMALLINT 类型(0=pending, 1=processing, 2=completed, 3=failed),代码中使用枚举 4. **AI 参数**:ai_prompt 和 ai_params 记录生成参数,便于重新生成 5. **文件去重**:checksum 字段必填,通过 file_checksums 表实现全局去重 6. **存储信息**:storage_provider 和 storage_path 记录文件在对象存储中的位置 7. **级联删除**:分镜删除时自动删除所有图片 ### 2. storyboard_videos 表 ```sql 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); ``` **设计说明**: 1. **视频元数据**:duration、resolution、frame_rate 等完整信息 2. **生成时间**:started_at 和 completed_at 记录生成耗时 3. **状态跟踪**:status 使用 SMALLINT 类型(0=pending, 1=processing, 2=completed, 3=failed),代码中使用枚举 4. **文件去重**:checksum 字段必填,通过 file_checksums 表实现全局去重 5. **存储信息**:storage_provider 和 storage_path 记录文件在对象存储中的位置 6. **其他设计**:与 storyboard_images 类似 ### 3. storyboard_dialogues 表 ```sql 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(); ``` **设计说明**: 1. **角色关联**:character_id 关联剧本角色,character_name 冗余存储 2. **对白类型**:dialogue_type 区分普通对白、内心OS、旁白三种类型 3. **顺序管理**:sequence_order 唯一约束,确保顺序不重复 4. **时间信息**:start_time 和 duration 用于分镜看板定位 5. **情绪标记**:emotion 字段用于 TTS 生成时的情感控制 **对白类型说明**: | 类型值 | 字符串值 | 说明 | 使用场景 | |-------|---------|------|---------| | 1 | normal | 普通对白 | 角色之间的正常对话 | | 2 | inner_monologue | 内心OS | 角色的内心独白,其他角色听不到 | | 3 | narration | 旁白 | 画外音、解说、回忆旁白等 | ### 4. storyboard_voiceovers 表 ```sql 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); ``` **设计说明**: 1. **双重关联**:关联 dialogue_id 和 storyboard_id(冗余,便于查询) 2. **TTS 参数**:voice_id、speed、volume、pitch 完整记录 3. **激活版本**:每条对白只能有一个激活配音 4. **状态跟踪**:status 使用 SMALLINT 类型(0=pending, 1=processing, 2=completed, 3=failed),代码中使用枚举 5. **音频元数据**:duration、file_size、format 等信息 6. **文件去重**:checksum 字段必填,通过 file_checksums 表实现全局去重 7. **存储信息**:storage_provider 和 storage_path 记录文件在对象存储中的位置 --- ## 服务实现 ### 状态枚举定义 ```python # app/models/storyboard_resource.py from enum import IntEnum class ResourceStatus(IntEnum): """资源生成状态""" PENDING = 0 # 等待生成 PROCESSING = 1 # 生成中 COMPLETED = 2 # 生成完成 FAILED = 3 # 生成失败 ``` ### StoryboardResourceService 类 ```python # 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 获取分镜的所有图片版本 ```http GET /api/v1/storyboards/{storyboard_id}/images ``` **响应**: ```json { "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 获取激活的图片 ```http GET /api/v1/storyboards/{storyboard_id}/images/active ``` #### 1.3 设置激活图片 ```http POST /api/v1/storyboard-images/{image_id}/activate ``` #### 1.4 删除图片 ```http DELETE /api/v1/storyboard-images/{image_id} ``` ### 2. 分镜视频 API #### 2.1 获取分镜的所有视频版本 ```http GET /api/v1/storyboards/{storyboard_id}/videos ``` #### 2.2 获取激活的视频 ```http GET /api/v1/storyboards/{storyboard_id}/videos/active ``` #### 2.3 设置激活视频 ```http POST /api/v1/storyboard-videos/{video_id}/activate ``` ### 3. 对白 API #### 3.1 获取分镜的所有对白 ```http GET /api/v1/storyboards/{storyboard_id}/dialogues ``` **响应**: ```json { "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 创建对白 ```http POST /api/v1/storyboards/{storyboard_id}/dialogues ``` **请求体**: ```json { "character_id": "019d1234-5678-7abc-def0-444444444444", "character_name": "张三", "content": "你好,最近怎么样?", "sequence_order": 1, "start_time": 0.5, "duration": 2.5, "emotion": "friendly" } ``` #### 3.3 更新对白 ```http PATCH /api/v1/dialogues/{dialogue_id} ``` #### 3.4 删除对白 ```http DELETE /api/v1/dialogues/{dialogue_id} ``` #### 3.5 重新排序对白 ```http POST /api/v1/storyboards/{storyboard_id}/dialogues/reorder ``` **请求体**: ```json { "dialogue_orders": { "019d1234-5678-7abc-def0-333333333333": 1, "019d1234-5678-7abc-def0-555555555555": 2 } } ``` ### 4. 配音 API #### 4.1 获取对白的所有配音版本 ```http GET /api/v1/dialogues/{dialogue_id}/voiceovers ``` **响应**: ```json { "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 获取激活的配音 ```http GET /api/v1/dialogues/{dialogue_id}/voiceovers/active ``` #### 4.3 设置激活配音 ```http POST /api/v1/voiceovers/{voiceover_id}/activate ``` --- --- ## 文件存储集成 ### 与 FileStorageService 的集成 分镜资源服务通过 `FileStorageService` 实现文件去重和引用计数管理: #### 1. 文件上传流程 ```python # 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. 文件删除流程 ```python 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`:文件访问 URL - `checksum`:SHA256 校验和(必填) - `storage_provider`:存储提供商(minio/s3/oss) - `storage_path`:对象存储中的路径 **优势**: - 不依赖 `attachments` 表,语义更清晰 - 通过 `checksum` 关联 `file_checksums` 表实现去重 - 支持跨表去重(与 `attachments`、`project_resources` 等共享去重机制) --- ## 数据库迁移脚本 ### Alembic 迁移示例 ```python """添加分镜资源表 Revision ID: 20260129_1800_add_storyboard_resources Revises: 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 = '' 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') ``` --- ## 相关文档 - [分镜管理服务](./storyboard-service.md) - [项目素材服务](./project-resource-service.md) - [分镜看板服务](./storyboard-board-service.md) - [文件存储服务](../resource/file-storage-service.md) - [AI 服务](../ai/ai-service.md) --- ## 变更记录 **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