# 分镜音效服务 > **文档版本**:v1.0 > **最后更新**:2026-02-12 --- ## 目录 1. [服务概述](#服务概述) 2. [核心功能](#核心功能) 3. [数据库设计](#数据库设计) 4. [服务实现](#服务实现) 5. [API 接口](#api-接口) --- ## 服务概述 分镜音效服务(StoryboardSoundEffectService)负责管理分镜的所有音效资源,包括环境音效、背景音乐、氛围音等 AI 生成的音频资源。这些音效是分镜的"声音层",与对白配音(voiceovers)区分开来。 ### 职责 - 音效管理(AI 生成的环境音、背景音乐、氛围音) - 音效分类(环境音、背景音乐、特效音、氛围音) - 版本管理(支持多版本生成和切换) - 音效参数管理(音量、淡入淡出、循环播放) - 时间轴定位(音效在分镜中的起止时间) ### 设计原则 - **分镜专属**:所有音效归属单个分镜,不可跨分镜复用 - **版本管理**:支持多版本生成,用户可选择激活版本 - **异步生成**:支持 AI 异步生成,状态跟踪 - **生命周期绑定**:音效随分镜删除而删除 - **分层管理**:支持多个音效同时存在(如背景音乐 + 环境音) ### 与其他资源的区别 | 特性 | 分镜音效(sound_effects) | 对白配音(voiceovers) | 项目素材(project_resources) | | -------- | ----------------------------- | ----------------------------- | ----------------------------- | | 归属 | 归属单个分镜 | 归属单个对白 | 归属项目,可被多个分镜使用 | | 来源 | AI 生成(环境音、背景音乐) | TTS 生成(对白文本转语音) | 用户上传或 AI 生成 | | 用途 | 氛围营造、场景渲染 | 角色对话 | 素材库、分镜制作 | | 复用性 | 不可复用 | 不可复用 | 可复用 | | 版本管理 | 支持多版本 | 支持多版本 | 单一版本 | | 数量 | 一个分镜可以有多个音效 | 一条对白只有一个激活配音 | 项目级别 | --- ## 核心功能 ### 1. 音效分类 使用 `SoundEffectType` 枚举: - `BACKGROUND_MUSIC (1)`:背景音乐(如轻音乐、配乐) - `AMBIENT (2)`:环境音(如风声、雨声、街道噪音) - `SOUND_EFFECT (3)`:特效音(如开门声、脚步声、爆炸声) - `ATMOSPHERE (4)`:氛围音(如紧张、悬疑、欢快的氛围音) ### 2. 音效生成 #### 2.1 AI 生成音效 - 根据分镜描述生成音效 - 支持多次生成(产生多个版本) - 用户选择激活版本 #### 2.2 版本管理 - 每次生成创建新版本 - 保留历史版本 - 切换激活版本 #### 2.3 状态跟踪 使用 `ResourceStatus` 枚举(与其他资源一致): - `PENDING (0)`:等待生成 - `PROCESSING (1)`:生成中 - `COMPLETED (2)`:生成完成 - `FAILED (3)`:生成失败 ### 3. 音效参数 #### 3.1 播放参数 - **音量**:0.0-1.0(默认 1.0) - **淡入时长**:音效开始时的淡入时间(秒) - **淡出时长**:音效结束时的淡出时间(秒) - **循环播放**:是否循环播放(适用于背景音乐、环境音) #### 3.2 时间轴定位 - **开始时间**:音效在分镜中的开始时间(秒) - **时长**:音效的持续时间(秒) - **结束时间**:自动计算(start_time + duration) ### 4. 多音效支持 - 一个分镜可以同时有多个音效 - 不同类型的音效可以叠加(如背景音乐 + 环境音 + 特效音) - 每个音效独立管理版本和激活状态 --- ## 数据库设计 ### storyboard_sound_effects 表 ```sql CREATE TABLE storyboard_sound_effects ( sound_effect_id UUID PRIMARY KEY, 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, -- 当前使用的版本 version INTEGER NOT NULL DEFAULT 1, -- 音效分类 effect_type SMALLINT NOT NULL, -- 1=background_music, 2=ambient, 3=sound_effect, 4=atmosphere effect_name TEXT, -- 音效名称(如"雨声"、"轻音乐") -- 播放参数 volume NUMERIC(3, 2) DEFAULT 1.0, -- 音量(0.0-1.0) fade_in_duration NUMERIC(10, 3) DEFAULT 0.0, -- 淡入时长(秒) fade_out_duration NUMERIC(10, 3) DEFAULT 0.0, -- 淡出时长(秒) loop_enabled BOOLEAN DEFAULT false, -- 是否循环播放 -- 时间信息 start_time NUMERIC(10, 3) NOT NULL DEFAULT 0.0, -- 在分镜中的开始时间(秒) duration NUMERIC(10, 3), -- 音效时长(秒) -- 音频元数据 file_size BIGINT, format TEXT, -- mp3/wav/ogg sample_rate INTEGER, -- 采样率(如 44100) bit_rate INTEGER, -- 比特率(如 128000) 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_sound_effects_type_active_unique UNIQUE (storyboard_id, effect_type, is_active) WHERE is_active = true ); -- 表注释 COMMENT ON TABLE storyboard_sound_effects IS '分镜音效表 - 存储AI生成的环境音、背景音乐等音频资源,支持多版本管理'; -- 字段注释 COMMENT ON COLUMN storyboard_sound_effects.sound_effect_id IS '音效ID(主键)'; COMMENT ON COLUMN storyboard_sound_effects.storyboard_id IS '所属分镜ID'; COMMENT ON COLUMN storyboard_sound_effects.audio_url IS '音频文件URL'; COMMENT ON COLUMN storyboard_sound_effects.status IS '生成状态: 0=pending, 1=processing, 2=completed, 3=failed'; COMMENT ON COLUMN storyboard_sound_effects.is_active IS '是否为当前激活版本(同一类型只能有一个激活音效)'; COMMENT ON COLUMN storyboard_sound_effects.version IS '版本号(每次生成递增)'; COMMENT ON COLUMN storyboard_sound_effects.effect_type IS '音效类型: 1=background_music, 2=ambient, 3=sound_effect, 4=atmosphere'; COMMENT ON COLUMN storyboard_sound_effects.effect_name IS '音效名称(如"雨声"、"轻音乐")'; COMMENT ON COLUMN storyboard_sound_effects.volume IS '音量(0.0-1.0)'; COMMENT ON COLUMN storyboard_sound_effects.fade_in_duration IS '淡入时长(秒)'; COMMENT ON COLUMN storyboard_sound_effects.fade_out_duration IS '淡出时长(秒)'; COMMENT ON COLUMN storyboard_sound_effects.loop_enabled IS '是否循环播放'; COMMENT ON COLUMN storyboard_sound_effects.start_time IS '在分镜中的开始时间(秒)'; COMMENT ON COLUMN storyboard_sound_effects.duration IS '音效时长(秒)'; COMMENT ON COLUMN storyboard_sound_effects.file_size IS '文件大小(字节)'; COMMENT ON COLUMN storyboard_sound_effects.format IS '音频格式(mp3/wav/ogg)'; COMMENT ON COLUMN storyboard_sound_effects.sample_rate IS '采样率(如 44100)'; COMMENT ON COLUMN storyboard_sound_effects.bit_rate IS '比特率(如 128000)'; COMMENT ON COLUMN storyboard_sound_effects.checksum IS 'SHA256校验和,用于文件去重'; COMMENT ON COLUMN storyboard_sound_effects.storage_provider IS '存储提供商(minio/s3/oss)'; COMMENT ON COLUMN storyboard_sound_effects.storage_path IS '对象存储中的路径'; COMMENT ON COLUMN storyboard_sound_effects.ai_model IS 'AI模型名称'; COMMENT ON COLUMN storyboard_sound_effects.ai_prompt IS '生成提示词(如"下雨的声音")'; COMMENT ON COLUMN storyboard_sound_effects.ai_params IS 'AI生成参数(风格、种子等)'; COMMENT ON COLUMN storyboard_sound_effects.error_message IS '错误信息(生成失败时)'; COMMENT ON COLUMN storyboard_sound_effects.created_at IS '创建时间'; COMMENT ON COLUMN storyboard_sound_effects.completed_at IS '完成时间'; -- 索引 CREATE INDEX idx_storyboard_sound_effects_storyboard_id ON storyboard_sound_effects(storyboard_id); CREATE INDEX idx_storyboard_sound_effects_status ON storyboard_sound_effects(status); CREATE INDEX idx_storyboard_sound_effects_type ON storyboard_sound_effects(effect_type); CREATE INDEX idx_storyboard_sound_effects_checksum ON storyboard_sound_effects(checksum); CREATE INDEX idx_storyboard_sound_effects_active ON storyboard_sound_effects(storyboard_id, is_active) WHERE is_active = true; ``` **设计说明**: 1. **音效分类**:effect_type 使用 SMALLINT 类型(1=background_music, 2=ambient, 3=sound_effect, 4=atmosphere) 2. **版本管理**:version 字段递增,支持多版本 3. **激活版本**:同一类型的音效只能有一个激活版本(通过唯一约束实现) 4. **多音效支持**:一个分镜可以同时有多个不同类型的激活音效 5. **播放参数**:volume、fade_in_duration、fade_out_duration、loop_enabled 控制播放效果 6. **时间轴**:start_time 和 duration 用于分镜看板定位 7. **状态跟踪**:status 使用 SMALLINT 类型(0=pending, 1=processing, 2=completed, 3=failed) 8. **文件去重**:checksum 字段必填,通过 file_checksums 表实现全局去重 9. **存储信息**:storage_provider 和 storage_path 记录文件在对象存储中的位置 10. **AI 参数**:ai_prompt 和 ai_params 记录生成参数,便于重新生成 --- ## 服务实现 ### 枚举定义 ```python # app/models/storyboard_sound_effect.py from enum import IntEnum class SoundEffectType(IntEnum): """音效类型""" BACKGROUND_MUSIC = 1 # 背景音乐 AMBIENT = 2 # 环境音 SOUND_EFFECT = 3 # 特效音 ATMOSPHERE = 4 # 氛围音 class ResourceStatus(IntEnum): """资源生成状态(与其他资源一致)""" PENDING = 0 # 等待生成 PROCESSING = 1 # 生成中 COMPLETED = 2 # 生成完成 FAILED = 3 # 生成失败 ``` ### StoryboardSoundEffectService 类 ```python # app/services/storyboard_sound_effect_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_sound_effect import ( StoryboardSoundEffect, SoundEffectType, ResourceStatus ) from app.repositories.storyboard_sound_effect_repository import StoryboardSoundEffectRepository from app.repositories.storyboard_repository import StoryboardRepository from app.services.file_storage_service import FileStorageService from app.schemas.storyboard_sound_effect import ( SoundEffectCreate, SoundEffectUpdate ) from app.core.exceptions import NotFoundError, ValidationError, PermissionError logger = logging.getLogger(__name__) class StoryboardSoundEffectService: def __init__(self, db: Session): self.repository = StoryboardSoundEffectRepository(db) self.storyboard_repo = StoryboardRepository(db) self.file_storage = FileStorageService(db) self.db = db # ==================== 音效管理 ==================== async def create_sound_effect( self, user_id: UUID, storyboard_id: UUID, effect_data: SoundEffectCreate ) -> StoryboardSoundEffect: """创建分镜音效(AI 生成后调用)""" logger.info( "用户 %s 为分镜 %s 创建音效,类型: %s", user_id, storyboard_id, effect_data.effect_type ) try: # 检查分镜权限 await self._check_storyboard_permission( user_id, storyboard_id, 'editor' ) # 获取该类型音效的下一个版本号 max_version = await self.repository.get_max_version_by_type( storyboard_id, effect_data.effect_type ) version = (max_version or 0) + 1 # 检查文件是否已存在(通过 checksum) existing_file = await self.file_storage.get_by_checksum(effect_data.checksum) if existing_file: # 文件已存在,增加引用计数 await self.file_storage.increase_reference_count(effect_data.checksum) audio_url = existing_file.file_url storage_provider = existing_file.storage_provider storage_path = existing_file.storage_path logger.debug( "文件已存在,复用: checksum=%s", effect_data.checksum ) else: # 文件不存在,使用传入的 URL(AI 服务已上传) audio_url = effect_data.audio_url storage_provider = effect_data.storage_provider or 'minio' storage_path = effect_data.storage_path sound_effect = StoryboardSoundEffect( storyboard_id=storyboard_id, audio_url=audio_url, status=effect_data.status, is_active=effect_data.is_active if effect_data.is_active is not None else False, version=version, effect_type=effect_data.effect_type, effect_name=effect_data.effect_name, volume=effect_data.volume or Decimal('1.0'), fade_in_duration=effect_data.fade_in_duration or Decimal('0.0'), fade_out_duration=effect_data.fade_out_duration or Decimal('0.0'), loop_enabled=effect_data.loop_enabled or False, start_time=effect_data.start_time or Decimal('0.0'), duration=effect_data.duration, file_size=effect_data.file_size, format=effect_data.format, sample_rate=effect_data.sample_rate, bit_rate=effect_data.bit_rate, checksum=effect_data.checksum, storage_provider=storage_provider, storage_path=storage_path, ai_model=effect_data.ai_model, ai_prompt=effect_data.ai_prompt, ai_params=effect_data.ai_params or {} ) created_effect = await self.repository.create(sound_effect) logger.info( "音效创建成功: sound_effect_id=%s, type=%s, version=%d", created_effect.sound_effect_id, effect_data.effect_type, version ) return created_effect except Exception as e: logger.error( "创建音效失败: user_id=%s, storyboard_id=%s", user_id, storyboard_id, exc_info=True ) raise async def get_sound_effects( self, user_id: UUID, storyboard_id: UUID, effect_type: Optional[SoundEffectType] = None ) -> List[StoryboardSoundEffect]: """获取分镜的所有音效(可按类型筛选)""" await self._check_storyboard_permission(user_id, storyboard_id, 'viewer') if effect_type: return await self.repository.get_by_storyboard_and_type( storyboard_id, effect_type ) return await self.repository.get_by_storyboard(storyboard_id) async def get_active_sound_effects( self, user_id: UUID, storyboard_id: UUID ) -> List[StoryboardSoundEffect]: """获取分镜的所有激活音效""" await self._check_storyboard_permission(user_id, storyboard_id, 'viewer') return await self.repository.get_active_effects(storyboard_id) async def get_active_effect_by_type( self, user_id: UUID, storyboard_id: UUID, effect_type: SoundEffectType ) -> Optional[StoryboardSoundEffect]: """获取指定类型的激活音效""" await self._check_storyboard_permission(user_id, storyboard_id, 'viewer') return await self.repository.get_active_effect_by_type( storyboard_id, effect_type ) async def set_active_sound_effect( self, user_id: UUID, sound_effect_id: UUID ) -> StoryboardSoundEffect: """设置激活音效""" effect = await self.repository.get_by_id(sound_effect_id) if not effect: raise NotFoundError("音效不存在") await self._check_storyboard_permission( user_id, effect.storyboard_id, 'editor' ) # 取消该类型的当前激活音效 await self.repository.deactivate_by_type( effect.storyboard_id, effect.effect_type ) # 激活新音效 effect.is_active = True updated_effect = await self.repository.update(effect) logger.info( "音效已激活: sound_effect_id=%s, type=%s", sound_effect_id, effect.effect_type ) return updated_effect async def update_sound_effect( self, user_id: UUID, sound_effect_id: UUID, effect_data: SoundEffectUpdate ) -> StoryboardSoundEffect: """更新音效参数(音量、淡入淡出等)""" effect = await self.repository.get_by_id(sound_effect_id) if not effect: raise NotFoundError("音效不存在") await self._check_storyboard_permission( user_id, effect.storyboard_id, 'editor' ) update_data = effect_data.dict(exclude_unset=True) updated_effect = await self.repository.update_by_id( sound_effect_id, update_data ) logger.info( "音效已更新: sound_effect_id=%s", sound_effect_id ) return updated_effect async def delete_sound_effect( self, user_id: UUID, sound_effect_id: UUID ) -> None: """删除音效""" effect = await self.repository.get_by_id(sound_effect_id) if not effect: raise NotFoundError("音效不存在") await self._check_storyboard_permission( user_id, effect.storyboard_id, 'editor' ) # 不允许删除激活的音效 if effect.is_active: raise ValidationError("不能删除激活的音效,请先切换到其他版本") # 删除数据库记录 await self.repository.delete(sound_effect_id) # 减少文件引用计数 await self.file_storage.decrease_reference_count(effect.checksum) logger.info( "音效已删除: sound_effect_id=%s", sound_effect_id ) async def deactivate_sound_effect( self, user_id: UUID, sound_effect_id: UUID ) -> None: """停用音效(不删除,只是取消激活)""" effect = await self.repository.get_by_id(sound_effect_id) if not effect: raise NotFoundError("音效不存在") await self._check_storyboard_permission( user_id, effect.storyboard_id, 'editor' ) effect.is_active = False await self.repository.update(effect) logger.info( "音效已停用: sound_effect_id=%s", sound_effect_id ) # ==================== 辅助方法 ==================== 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}/sound-effects ``` **查询参数**: - `effect_type`(可选):音效类型(1=background_music, 2=ambient, 3=sound_effect, 4=atmosphere) **响应**: ```json { "success": true, "code": 200, "message": "Success", "data": [ { "sound_effect_id": "019d1234-5678-7abc-def0-111111111111", "storyboard_id": "019d1234-5678-7abc-def0-222222222222", "audio_url": "https://storage.example.com/audio/rain.mp3", "status": 2, "is_active": true, "version": 1, "effect_type": 2, "effect_name": "雨声", "volume": 0.8, "fade_in_duration": 2.0, "fade_out_duration": 2.0, "loop_enabled": true, "start_time": 0.0, "duration": 30.0, "file_size": 512000, "format": "mp3", "sample_rate": 44100, "bit_rate": 128000, "ai_model": "audio-gen-v1", "ai_prompt": "下雨的声音,中等雨量", "created_at": "2026-02-12T10:00:00Z", "completed_at": "2026-02-12T10:01:30Z" } ], "timestamp": "2026-02-12T10:00:00Z" } ``` #### 1.2 获取激活的音效 ```http GET /api/v1/storyboards/{storyboard_id}/sound-effects/active ``` **响应**:返回所有激活的音效(可能有多个不同类型) #### 1.3 获取指定类型的激活音效 ```http GET /api/v1/storyboards/{storyboard_id}/sound-effects/active/{effect_type} ``` **路径参数**: - `effect_type`:音效类型(1-4) #### 1.4 创建音效 ```http POST /api/v1/storyboards/{storyboard_id}/sound-effects ``` **请求体**: ```json { "audio_url": "https://storage.example.com/audio/rain.mp3", "status": 2, "is_active": true, "effect_type": 2, "effect_name": "雨声", "volume": 0.8, "fade_in_duration": 2.0, "fade_out_duration": 2.0, "loop_enabled": true, "start_time": 0.0, "duration": 30.0, "file_size": 512000, "format": "mp3", "sample_rate": 44100, "bit_rate": 128000, "checksum": "abc123...", "storage_provider": "minio", "storage_path": "/audio/rain.mp3", "ai_model": "audio-gen-v1", "ai_prompt": "下雨的声音,中等雨量", "ai_params": {} } ``` #### 1.5 更新音效参数 ```http PATCH /api/v1/sound-effects/{sound_effect_id} ``` **请求体**: ```json { "volume": 0.6, "fade_in_duration": 3.0, "loop_enabled": false } ``` #### 1.6 设置激活音效 ```http POST /api/v1/sound-effects/{sound_effect_id}/activate ``` #### 1.7 停用音效 ```http POST /api/v1/sound-effects/{sound_effect_id}/deactivate ``` #### 1.8 删除音效 ```http DELETE /api/v1/sound-effects/{sound_effect_id} ``` --- ## 文件存储集成 ### 与 FileStorageService 的集成 分镜音效服务通过 `FileStorageService` 实现文件去重和引用计数管理: #### 1. 文件上传流程 ```python # AI 服务生成音效后调用 async def create_sound_effect(self, user_id, storyboard_id, effect_data): # 1. 检查文件是否已存在(通过 checksum) existing_file = await self.file_storage.get_by_checksum(effect_data.checksum) if existing_file: # 2a. 文件已存在,增加引用计数 await self.file_storage.increase_reference_count(effect_data.checksum) audio_url = existing_file.file_url else: # 2b. 文件不存在,使用 AI 服务已上传的 URL audio_url = effect_data.audio_url # 3. 创建音效记录 sound_effect = StoryboardSoundEffect( audio_url=audio_url, checksum=effect_data.checksum, storage_provider=effect_data.storage_provider, storage_path=effect_data.storage_path, # ... ) return await self.repository.create(sound_effect) ``` #### 2. 文件删除流程 ```python async def delete_sound_effect(self, user_id, sound_effect_id): effect = await self.repository.get_by_id(sound_effect_id) # 1. 删除数据库记录 await self.repository.delete(sound_effect_id) # 2. 减少文件引用计数 # 如果引用计数为 0,FileStorageService 会自动删除文件 await self.file_storage.decrease_reference_count(effect.checksum) ``` #### 3. 去重机制 - 所有音效通过 `checksum` 字段关联 `file_checksums` 表 - 相同音效文件只存储一次,节省存储空间 - 引用计数管理确保文件安全删除 - 与其他资源(图片、视频、配音)共享去重机制 --- ## 使用场景 ### 场景 1:为分镜添加背景音乐 ```python # 1. AI 生成背景音乐 effect_data = SoundEffectCreate( audio_url="https://storage.example.com/audio/bgm.mp3", status=ResourceStatus.COMPLETED, is_active=True, effect_type=SoundEffectType.BACKGROUND_MUSIC, effect_name="轻音乐", volume=0.6, loop_enabled=True, start_time=0.0, duration=60.0, checksum="abc123...", # ... ) # 2. 创建音效 bgm = await service.create_sound_effect(user_id, storyboard_id, effect_data) ``` ### 场景 2:叠加多个音效 ```python # 1. 添加背景音乐 bgm = await service.create_sound_effect(user_id, storyboard_id, bgm_data) # 2. 添加环境音(雨声) ambient = await service.create_sound_effect(user_id, storyboard_id, ambient_data) # 3. 添加特效音(开门声) sfx = await service.create_sound_effect(user_id, storyboard_id, sfx_data) # 4. 获取所有激活音效 active_effects = await service.get_active_sound_effects(user_id, storyboard_id) # 返回:[bgm, ambient, sfx] ``` ### 场景 3:切换音效版本 ```python # 1. 生成新版本的背景音乐 new_bgm = await service.create_sound_effect(user_id, storyboard_id, new_bgm_data) # 2. 激活新版本 await service.set_active_sound_effect(user_id, new_bgm.sound_effect_id) # 旧版本自动停用 ``` ### 场景 4:调整音效参数 ```python # 调整音量和淡入淡出 update_data = SoundEffectUpdate( volume=0.4, fade_in_duration=5.0, fade_out_duration=5.0 ) await service.update_sound_effect(user_id, sound_effect_id, update_data) ``` --- ## 数据库迁移脚本 ### Alembic 迁移示例 ```python """添加分镜音效表 Revision ID: 20260212_1000_add_storyboard_sound_effects Revises: Create Date: 2026-02-12 10:00:00.000000 """ from alembic import op import sqlalchemy as sa from sqlalchemy.dialects import postgresql # revision identifiers, used by Alembic. revision = '20260212_1000_add_storyboard_sound_effects' down_revision = '' branch_labels = None depends_on = None def upgrade() -> None: # 创建 storyboard_sound_effects 表 op.create_table( 'storyboard_sound_effects', sa.Column('sound_effect_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('version', sa.Integer(), nullable=False, server_default='1'), sa.Column('effect_type', sa.SmallInteger(), nullable=False), sa.Column('effect_name', sa.Text(), nullable=True), sa.Column('volume', sa.Numeric(3, 2), server_default='1.0'), sa.Column('fade_in_duration', sa.Numeric(10, 3), server_default='0.0'), sa.Column('fade_out_duration', sa.Numeric(10, 3), server_default='0.0'), sa.Column('loop_enabled', sa.Boolean(), server_default='false'), sa.Column('start_time', sa.Numeric(10, 3), nullable=False, server_default='0.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('sample_rate', sa.Integer(), nullable=True), sa.Column('bit_rate', sa.Integer(), 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('sound_effect_id'), sa.UniqueConstraint( 'storyboard_id', 'effect_type', 'is_active', name='storyboard_sound_effects_type_active_unique', postgresql_where=sa.text('is_active = true') ) ) # 表注释 op.execute("COMMENT ON TABLE storyboard_sound_effects IS '分镜音效表 - 存储AI生成的环境音、背景音乐等音频资源,支持多版本管理'") # 列注释 op.execute("COMMENT ON COLUMN storyboard_sound_effects.sound_effect_id IS '音效ID(主键)'") op.execute("COMMENT ON COLUMN storyboard_sound_effects.storyboard_id IS '所属分镜ID'") op.execute("COMMENT ON COLUMN storyboard_sound_effects.status IS '生成状态: 0=pending, 1=processing, 2=completed, 3=failed'") op.execute("COMMENT ON COLUMN storyboard_sound_effects.is_active IS '是否为当前激活版本(同一类型只能有一个激活音效)'") op.execute("COMMENT ON COLUMN storyboard_sound_effects.effect_type IS '音效类型: 1=background_music, 2=ambient, 3=sound_effect, 4=atmosphere'") op.execute("COMMENT ON COLUMN storyboard_sound_effects.volume IS '音量(0.0-1.0)'") op.execute("COMMENT ON COLUMN storyboard_sound_effects.fade_in_duration IS '淡入时长(秒)'") op.execute("COMMENT ON COLUMN storyboard_sound_effects.fade_out_duration IS '淡出时长(秒)'") op.execute("COMMENT ON COLUMN storyboard_sound_effects.loop_enabled IS '是否循环播放'") op.execute("COMMENT ON COLUMN storyboard_sound_effects.start_time IS '在分镜中的开始时间(秒)'") op.execute("COMMENT ON COLUMN storyboard_sound_effects.duration IS '音效时长(秒)'") op.execute("COMMENT ON COLUMN storyboard_sound_effects.checksum IS 'SHA256校验和,用于文件去重'") # 索引 op.create_index('idx_storyboard_sound_effects_storyboard_id', 'storyboard_sound_effects', ['storyboard_id']) op.create_index('idx_storyboard_sound_effects_status', 'storyboard_sound_effects', ['status']) op.create_index('idx_storyboard_sound_effects_type', 'storyboard_sound_effects', ['effect_type']) op.create_index('idx_storyboard_sound_effects_checksum', 'storyboard_sound_effects', ['checksum']) op.create_index( 'idx_storyboard_sound_effects_active', 'storyboard_sound_effects', ['storyboard_id', 'is_active'], postgresql_where=sa.text('is_active = true') ) def downgrade() -> None: op.drop_table('storyboard_sound_effects') ``` --- ## 相关文档 - [分镜资源服务](./storyboard-resource-service.md) - [分镜管理服务](./storyboard-service.md) - [项目素材服务](./project-resource-service.md) - [文件存储服务](../resource/file-storage-service.md) - [AI 服务](../ai/ai-service.md) --- ## 变更记录 **v1.0 (2026-02-12)** - ✅ 初始版本 - ✅ 定义分镜音效服务架构 - ✅ 实现音效分类(背景音乐、环境音、特效音、氛围音) - ✅ 支持版本管理和激活切换 - ✅ 支持多音效叠加 - ✅ 集成 FileStorageService 实现文件去重 - ✅ 符合 jointo-tech-stack v1.0 规范 --- **文档版本**:v1.0 **最后更新**:2026-02-12