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

分镜音效服务

文档版本:v1.0
最后更新:2026-02-12


目录

  1. 服务概述
  2. 核心功能
  3. 数据库设计
  4. 服务实现
  5. 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 表

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 记录生成参数,便于重新生成

服务实现

枚举定义

# 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