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.
32 KiB
32 KiB
分镜音效服务
文档版本:v1.0
最后更新:2026-02-12
目录
服务概述
分镜音效服务(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 表
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;
设计说明:
- 音效分类:effect_type 使用 SMALLINT 类型(1=background_music, 2=ambient, 3=sound_effect, 4=atmosphere)
- 版本管理:version 字段递增,支持多版本
- 激活版本:同一类型的音效只能有一个激活版本(通过唯一约束实现)
- 多音效支持:一个分镜可以同时有多个不同类型的激活音效
- 播放参数:volume、fade_in_duration、fade_out_duration、loop_enabled 控制播放效果
- 时间轴:start_time 和 duration 用于分镜看板定位
- 状态跟踪:status 使用 SMALLINT 类型(0=pending, 1=processing, 2=completed, 3=failed)
- 文件去重:checksum 字段必填,通过 file_checksums 表实现全局去重
- 存储信息:storage_provider 和 storage_path 记录文件在对象存储中的位置
- AI 参数:ai_prompt 和 ai_params 记录生成参数,便于重新生成
服务实现
枚举定义
# 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 类
# 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 获取分镜的所有音效
GET /api/v1/storyboards/{storyboard_id}/sound-effects
查询参数:
effect_type(可选):音效类型(1=background_music, 2=ambient, 3=sound_effect, 4=atmosphere)
响应:
{
"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 获取激活的音效
GET /api/v1/storyboards/{storyboard_id}/sound-effects/active
响应:返回所有激活的音效(可能有多个不同类型)
1.3 获取指定类型的激活音效
GET /api/v1/storyboards/{storyboard_id}/sound-effects/active/{effect_type}
路径参数:
effect_type:音效类型(1-4)
1.4 创建音效
POST /api/v1/storyboards/{storyboard_id}/sound-effects
请求体:
{
"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 更新音效参数
PATCH /api/v1/sound-effects/{sound_effect_id}
请求体:
{
"volume": 0.6,
"fade_in_duration": 3.0,
"loop_enabled": false
}
1.6 设置激活音效
POST /api/v1/sound-effects/{sound_effect_id}/activate
1.7 停用音效
POST /api/v1/sound-effects/{sound_effect_id}/deactivate
1.8 删除音效
DELETE /api/v1/sound-effects/{sound_effect_id}
文件存储集成
与 FileStorageService 的集成
分镜音效服务通过 FileStorageService 实现文件去重和引用计数管理:
1. 文件上传流程
# 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. 文件删除流程
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:为分镜添加背景音乐
# 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:叠加多个音效
# 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:切换音效版本
# 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:调整音效参数
# 调整音量和淡入淡出
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 迁移示例
"""添加分镜音效表
Revision ID: 20260212_1000_add_storyboard_sound_effects
Revises: <previous_revision>
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 = '<previous_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')
相关文档
变更记录
v1.0 (2026-02-12)
- ✅ 初始版本
- ✅ 定义分镜音效服务架构
- ✅ 实现音效分类(背景音乐、环境音、特效音、氛围音)
- ✅ 支持版本管理和激活切换
- ✅ 支持多音效叠加
- ✅ 集成 FileStorageService 实现文件去重
- ✅ 符合 jointo-tech-stack v1.0 规范
文档版本:v1.0
最后更新:2026-02-12