You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 

45 KiB

分镜资源服务

文档版本:v1.2
最后更新:2026-01-29


目录

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

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 表

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 表

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 表

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 记录文件在对象存储中的位置

服务实现

状态枚举定义

# app/models/storyboard_resource.py
from enum import IntEnum

class ResourceStatus(IntEnum):
    """资源生成状态"""
    PENDING = 0      # 等待生成
    PROCESSING = 1   # 生成中
    COMPLETED = 2    # 生成完成
    FAILED = 3       # 生成失败

StoryboardResourceService 类

# app/services/storyboard_resource_service.py
import logging
from typing import List, Optional, Dict, Any
from uuid import UUID
from decimal import Decimal
from sqlalchemy.orm import Session
from app.models.storyboard_resource import (
    StoryboardImage, StoryboardVideo,
    StoryboardDialogue, StoryboardVoiceover,
    ResourceStatus
)
from app.repositories.storyboard_resource_repository import StoryboardResourceRepository
from app.repositories.storyboard_repository import StoryboardRepository
from app.services.file_storage_service import FileStorageService
from app.schemas.storyboard_resource import (
    ImageCreate, VideoCreate,
    DialogueCreate, DialogueUpdate,
    VoiceoverCreate
)
from app.core.exceptions import NotFoundError, ValidationError, PermissionError

logger = logging.getLogger(__name__)

class StoryboardResourceService:
    def __init__(self, db: Session):
        self.repository = StoryboardResourceRepository(db)
        self.storyboard_repo = StoryboardRepository(db)
        self.file_storage = FileStorageService(db)
        self.db = db

    # ==================== 分镜图片管理 ====================

    async def create_image(
        self,
        user_id: UUID,
        storyboard_id: UUID,
        image_data: ImageCreate
    ) -> StoryboardImage:
        """创建分镜图片(AI 生成后调用)"""
        logger.info(
            "用户 %s 为分镜 %s 创建图片",
            user_id,
            storyboard_id
        )
        
        try:
            # 检查分镜权限
            storyboard = await self._check_storyboard_permission(
                user_id, storyboard_id, 'editor'
            )

            # 获取下一个版本号
            max_version = await self.repository.get_max_image_version(storyboard_id)
            version = (max_version or 0) + 1

            # 检查文件是否已存在(通过 checksum)
            existing_file = await self.file_storage.get_by_checksum(image_data.checksum)
            if existing_file:
                # 文件已存在,增加引用计数
                await self.file_storage.increase_reference_count(image_data.checksum)
                file_url = existing_file.file_url
                storage_provider = existing_file.storage_provider
                storage_path = existing_file.storage_path
                logger.debug(
                    "文件已存在,复用: checksum=%s",
                    image_data.checksum
                )
            else:
                # 文件不存在,使用传入的 URL(AI 服务已上传)
                file_url = image_data.url
                storage_provider = image_data.storage_provider or 'minio'
                storage_path = image_data.storage_path

            image = StoryboardImage(
                storyboard_id=storyboard_id,
                url=file_url,
                status=image_data.status,
                is_active=image_data.is_active if image_data.is_active is not None else False,
                version=version,
                width=image_data.width,
                height=image_data.height,
                file_size=image_data.file_size,
                format=image_data.format,
                checksum=image_data.checksum,
                storage_provider=storage_provider,
                storage_path=storage_path,
                ai_model=image_data.ai_model,
                ai_prompt=image_data.ai_prompt,
                ai_params=image_data.ai_params or {}
            )

            created_image = await self.repository.create_image(image)

            # 如果设置为激活,更新分镜的 thumbnail_url
            if image_data.is_active:
                await self.storyboard_repo.update(storyboard_id, {
                    'thumbnail_url': file_url,
                    'thumbnail_id': created_image.image_id
                })

            logger.info(
                "图片创建成功: image_id=%s, version=%d",
                created_image.image_id,
                version
            )
            return created_image
            
        except Exception as e:
            logger.error(
                "创建图片失败: user_id=%s, storyboard_id=%s",
                user_id,
                storyboard_id,
                exc_info=True
            )
            raise

    async def get_images(
        self,
        user_id: UUID,
        storyboard_id: UUID
    ) -> List[StoryboardImage]:
        """获取分镜的所有图片版本"""
        await self._check_storyboard_permission(user_id, storyboard_id, 'viewer')
        return await self.repository.get_images_by_storyboard(storyboard_id)

    async def get_active_image(
        self,
        user_id: UUID,
        storyboard_id: UUID
    ) -> Optional[StoryboardImage]:
        """获取分镜的激活图片"""
        await self._check_storyboard_permission(user_id, storyboard_id, 'viewer')
        return await self.repository.get_active_image(storyboard_id)

    async def set_active_image(
        self,
        user_id: UUID,
        image_id: UUID
    ) -> StoryboardImage:
        """设置激活图片"""
        image = await self.repository.get_image_by_id(image_id)
        if not image:
            raise NotFoundError("图片不存在")

        await self._check_storyboard_permission(
            user_id, image.storyboard_id, 'editor'
        )

        # 取消当前激活的图片
        await self.repository.deactivate_all_images(image.storyboard_id)

        # 激活新图片
        image.is_active = True
        updated_image = await self.repository.update_image(image)

        # 更新分镜的 thumbnail_url
        await self.storyboard_repo.update(image.storyboard_id, {
            'thumbnail_url': image.url,
            'thumbnail_id': image.image_id
        })

        return updated_image

    async def delete_image(
        self,
        user_id: UUID,
        image_id: UUID
    ) -> None:
        """删除分镜图片"""
        image = await self.repository.get_image_by_id(image_id)
        if not image:
            raise NotFoundError("图片不存在")

        await self._check_storyboard_permission(
            user_id, image.storyboard_id, 'editor'
        )

        # 不允许删除激活的图片
        if image.is_active:
            raise ValidationError("不能删除激活的图片,请先切换到其他版本")

        # 删除数据库记录
        await self.repository.delete_image(image_id)

        # 减少文件引用计数(如果引用计数为 0,FileStorageService 会自动删除文件)
        await self.file_storage.decrease_reference_count(image.checksum)

    # ==================== 分镜视频管理 ====================

    async def create_video(
        self,
        user_id: UUID,
        storyboard_id: UUID,
        video_data: VideoCreate
    ) -> StoryboardVideo:
        """创建分镜视频(AI 生成后调用)"""
        await self._check_storyboard_permission(user_id, storyboard_id, 'editor')

        # 获取下一个版本号
        max_version = await self.repository.get_max_video_version(storyboard_id)
        version = (max_version or 0) + 1

        # 检查文件是否已存在(通过 checksum)
        existing_file = await self.file_storage.get_by_checksum(video_data.checksum)
        if existing_file:
            # 文件已存在,增加引用计数
            await self.file_storage.increase_reference_count(video_data.checksum)
            file_url = existing_file.file_url
            storage_provider = existing_file.storage_provider
            storage_path = existing_file.storage_path
        else:
            # 文件不存在,使用传入的 URL(AI 服务已上传)
            file_url = video_data.url
            storage_provider = video_data.storage_provider or 'minio'
            storage_path = video_data.storage_path

        video = StoryboardVideo(
            storyboard_id=storyboard_id,
            url=file_url,
            status=video_data.status,
            is_active=video_data.is_active if video_data.is_active is not None else False,
            version=version,
            duration=video_data.duration,
            resolution=video_data.resolution,
            frame_rate=video_data.frame_rate,
            file_size=video_data.file_size,
            format=video_data.format,
            checksum=video_data.checksum,
            storage_provider=storage_provider,
            storage_path=storage_path,
            ai_model=video_data.ai_model,
            ai_params=video_data.ai_params or {}
        )

        return await self.repository.create_video(video)

    async def get_videos(
        self,
        user_id: UUID,
        storyboard_id: UUID
    ) -> List[StoryboardVideo]:
        """获取分镜的所有视频版本"""
        await self._check_storyboard_permission(user_id, storyboard_id, 'viewer')
        return await self.repository.get_videos_by_storyboard(storyboard_id)

    async def get_active_video(
        self,
        user_id: UUID,
        storyboard_id: UUID
    ) -> Optional[StoryboardVideo]:
        """获取分镜的激活视频"""
        await self._check_storyboard_permission(user_id, storyboard_id, 'viewer')
        return await self.repository.get_active_video(storyboard_id)

    async def set_active_video(
        self,
        user_id: UUID,
        video_id: UUID
    ) -> StoryboardVideo:
        """设置激活视频"""
        video = await self.repository.get_video_by_id(video_id)
        if not video:
            raise NotFoundError("视频不存在")

        await self._check_storyboard_permission(
            user_id, video.storyboard_id, 'editor'
        )

        # 取消当前激活的视频
        await self.repository.deactivate_all_videos(video.storyboard_id)

        # 激活新视频
        video.is_active = True
        return await self.repository.update_video(video)

    # ==================== 对白管理 ====================

    async def create_dialogue(
        self,
        user_id: UUID,
        storyboard_id: UUID,
        dialogue_data: DialogueCreate
    ) -> StoryboardDialogue:
        """创建对白"""
        await self._check_storyboard_permission(user_id, storyboard_id, 'editor')

        # 如果未指定顺序,自动分配
        if dialogue_data.sequence_order is None:
            max_order = await self.repository.get_max_dialogue_order(storyboard_id)
            sequence_order = (max_order or 0) + 1
        else:
            sequence_order = dialogue_data.sequence_order

        dialogue = StoryboardDialogue(
            storyboard_id=storyboard_id,
            character_id=dialogue_data.character_id,
            character_name=dialogue_data.character_name,
            content=dialogue_data.content,
            sequence_order=sequence_order,
            start_time=dialogue_data.start_time,
            duration=dialogue_data.duration,
            emotion=dialogue_data.emotion,
            notes=dialogue_data.notes
        )

        return await self.repository.create_dialogue(dialogue)

    async def get_dialogues(
        self,
        user_id: UUID,
        storyboard_id: UUID
    ) -> List[StoryboardDialogue]:
        """获取分镜的所有对白"""
        await self._check_storyboard_permission(user_id, storyboard_id, 'viewer')
        return await self.repository.get_dialogues_by_storyboard(storyboard_id)

    async def update_dialogue(
        self,
        user_id: UUID,
        dialogue_id: UUID,
        dialogue_data: DialogueUpdate
    ) -> StoryboardDialogue:
        """更新对白"""
        dialogue = await self.repository.get_dialogue_by_id(dialogue_id)
        if not dialogue:
            raise NotFoundError("对白不存在")

        await self._check_storyboard_permission(
            user_id, dialogue.storyboard_id, 'editor'
        )

        update_data = dialogue_data.dict(exclude_unset=True)
        return await self.repository.update_dialogue(dialogue_id, update_data)

    async def delete_dialogue(
        self,
        user_id: UUID,
        dialogue_id: UUID
    ) -> None:
        """删除对白"""
        dialogue = await self.repository.get_dialogue_by_id(dialogue_id)
        if not dialogue:
            raise NotFoundError("对白不存在")

        await self._check_storyboard_permission(
            user_id, dialogue.storyboard_id, 'editor'
        )

        await self.repository.delete_dialogue(dialogue_id)

    async def reorder_dialogues(
        self,
        user_id: UUID,
        storyboard_id: UUID,
        dialogue_orders: Dict[UUID, int]
    ) -> List[StoryboardDialogue]:
        """重新排序对白"""
        await self._check_storyboard_permission(user_id, storyboard_id, 'editor')

        # 批量更新顺序
        for dialogue_id, new_order in dialogue_orders.items():
            await self.repository.update_dialogue(dialogue_id, {
                'sequence_order': new_order
            })

        return await self.repository.get_dialogues_by_storyboard(storyboard_id)

    # ==================== 配音管理 ====================

    async def create_voiceover(
        self,
        user_id: UUID,
        dialogue_id: UUID,
        voiceover_data: VoiceoverCreate
    ) -> StoryboardVoiceover:
        """创建配音(TTS 生成后调用)"""
        dialogue = await self.repository.get_dialogue_by_id(dialogue_id)
        if not dialogue:
            raise NotFoundError("对白不存在")

        await self._check_storyboard_permission(
            user_id, dialogue.storyboard_id, 'editor'
        )

        # 检查文件是否已存在(通过 checksum)
        existing_file = await self.file_storage.get_by_checksum(voiceover_data.checksum)
        if existing_file:
            # 文件已存在,增加引用计数
            await self.file_storage.increase_reference_count(voiceover_data.checksum)
            audio_url = existing_file.file_url
            storage_provider = existing_file.storage_provider
            storage_path = existing_file.storage_path
        else:
            # 文件不存在,使用传入的 URL(TTS 服务已上传)
            audio_url = voiceover_data.audio_url
            storage_provider = voiceover_data.storage_provider or 'minio'
            storage_path = voiceover_data.storage_path

        voiceover = StoryboardVoiceover(
            dialogue_id=dialogue_id,
            storyboard_id=dialogue.storyboard_id,
            audio_url=audio_url,
            status=voiceover_data.status,
            is_active=voiceover_data.is_active if voiceover_data.is_active is not None else False,
            voice_id=voiceover_data.voice_id,
            voice_name=voiceover_data.voice_name,
            speed=voiceover_data.speed,
            volume=voiceover_data.volume,
            pitch=voiceover_data.pitch,
            duration=voiceover_data.duration,
            file_size=voiceover_data.file_size,
            format=voiceover_data.format,
            checksum=voiceover_data.checksum,
            storage_provider=storage_provider,
            storage_path=storage_path
        )

        return await self.repository.create_voiceover(voiceover)

    async def get_voiceovers(
        self,
        user_id: UUID,
        dialogue_id: UUID
    ) -> List[StoryboardVoiceover]:
        """获取对白的所有配音版本"""
        dialogue = await self.repository.get_dialogue_by_id(dialogue_id)
        if not dialogue:
            raise NotFoundError("对白不存在")

        await self._check_storyboard_permission(
            user_id, dialogue.storyboard_id, 'viewer'
        )

        return await self.repository.get_voiceovers_by_dialogue(dialogue_id)

    async def get_active_voiceover(
        self,
        user_id: UUID,
        dialogue_id: UUID
    ) -> Optional[StoryboardVoiceover]:
        """获取对白的激活配音"""
        dialogue = await self.repository.get_dialogue_by_id(dialogue_id)
        if not dialogue:
            raise NotFoundError("对白不存在")

        await self._check_storyboard_permission(
            user_id, dialogue.storyboard_id, 'viewer'
        )

        return await self.repository.get_active_voiceover(dialogue_id)

    async def set_active_voiceover(
        self,
        user_id: UUID,
        voiceover_id: UUID
    ) -> StoryboardVoiceover:
        """设置激活配音"""
        voiceover = await self.repository.get_voiceover_by_id(voiceover_id)
        if not voiceover:
            raise NotFoundError("配音不存在")

        dialogue = await self.repository.get_dialogue_by_id(voiceover.dialogue_id)
        await self._check_storyboard_permission(
            user_id, dialogue.storyboard_id, 'editor'
        )

        # 取消当前激活的配音
        await self.repository.deactivate_all_voiceovers(voiceover.dialogue_id)

        # 激活新配音
        voiceover.is_active = True
        return await self.repository.update_voiceover(voiceover)

    # ==================== 辅助方法 ====================

    async def _check_storyboard_permission(
        self,
        user_id: UUID,
        storyboard_id: UUID,
        required_role: str = 'viewer'
    ) -> Any:
        """检查分镜权限"""
        storyboard = await self.storyboard_repo.get_by_id(storyboard_id)
        if not storyboard:
            raise NotFoundError("分镜不存在")

        from app.repositories.project_repository import ProjectRepository
        project_repo = ProjectRepository(self.db)

        has_permission = await project_repo.check_user_permission(
            user_id, storyboard.project_id, required_role
        )
        if not has_permission:
            raise PermissionError("没有权限访问此分镜")

        return storyboard

API 接口

1. 分镜图片 API

1.1 获取分镜的所有图片版本

GET /api/v1/storyboards/{storyboard_id}/images

响应

{
  "success": true,
  "code": 200,
  "message": "Success",
  "data": [
    {
      "image_id": "019d1234-5678-7abc-def0-111111111111",
      "storyboard_id": "019d1234-5678-7abc-def0-222222222222",
      "url": "https://storage.example.com/images/abc123.png",
      "status": 2,
      "is_active": true,
      "version": 2,
      "width": 1920,
      "height": 1080,
      "file_size": 2048000,
      "format": "png",
      "ai_model": "stable-diffusion-xl",
      "ai_prompt": "一个年轻人在咖啡厅里...",
      "created_at": "2026-01-22T10:00:00Z",
      "completed_at": "2026-01-22T10:02:30Z"
    }
  ],
  "timestamp": "2026-02-03T10:00:00Z"
}

1.2 获取激活的图片

GET /api/v1/storyboards/{storyboard_id}/images/active

1.3 设置激活图片

POST /api/v1/storyboard-images/{image_id}/activate

1.4 删除图片

DELETE /api/v1/storyboard-images/{image_id}

2. 分镜视频 API

2.1 获取分镜的所有视频版本

GET /api/v1/storyboards/{storyboard_id}/videos

2.2 获取激活的视频

GET /api/v1/storyboards/{storyboard_id}/videos/active

2.3 设置激活视频

POST /api/v1/storyboard-videos/{video_id}/activate

3. 对白 API

3.1 获取分镜的所有对白

GET /api/v1/storyboards/{storyboard_id}/dialogues

响应

{
  "success": true,
  "code": 200,
  "message": "Success",
  "data": [
    {
      "dialogue_id": "019d1234-5678-7abc-def0-333333333333",
      "storyboard_id": "019d1234-5678-7abc-def0-222222222222",
      "character_id": "019d1234-5678-7abc-def0-444444444444",
      "character_name": "张三",
      "content": "你好,最近怎么样?",
      "sequence_order": 1,
      "start_time": 0.5,
      "duration": 2.5,
      "emotion": "friendly",
      "created_at": "2026-01-22T10:00:00Z"
    }
  ],
  "timestamp": "2026-02-03T10:00:00Z"
}

3.2 创建对白

POST /api/v1/storyboards/{storyboard_id}/dialogues

请求体

{
  "character_id": "019d1234-5678-7abc-def0-444444444444",
  "character_name": "张三",
  "content": "你好,最近怎么样?",
  "sequence_order": 1,
  "start_time": 0.5,
  "duration": 2.5,
  "emotion": "friendly"
}

3.3 更新对白

PATCH /api/v1/dialogues/{dialogue_id}

3.4 删除对白

DELETE /api/v1/dialogues/{dialogue_id}

3.5 重新排序对白

POST /api/v1/storyboards/{storyboard_id}/dialogues/reorder

请求体

{
  "dialogue_orders": {
    "019d1234-5678-7abc-def0-333333333333": 1,
    "019d1234-5678-7abc-def0-555555555555": 2
  }
}

4. 配音 API

4.1 获取对白的所有配音版本

GET /api/v1/dialogues/{dialogue_id}/voiceovers

响应

{
  "success": true,
  "code": 200,
  "message": "Success",
  "data": [
    {
      "voiceover_id": "019d1234-5678-7abc-def0-666666666666",
      "dialogue_id": "019d1234-5678-7abc-def0-333333333333",
      "audio_url": "https://storage.example.com/audio/voice123.mp3",
      "status": 2,
      "is_active": true,
      "voice_id": "zh-CN-XiaoxiaoNeural",
      "voice_name": "晓晓(温柔女声)",
      "speed": 1.0,
      "volume": 1.0,
      "pitch": 1.0,
      "duration": 2.5,
      "file_size": 40960,
      "format": "mp3",
      "created_at": "2026-01-22T10:05:00Z"
    }
  ],
  "timestamp": "2026-02-03T10:00:00Z"
}

4.2 获取激活的配音

GET /api/v1/dialogues/{dialogue_id}/voiceovers/active

4.3 设置激活配音

POST /api/v1/voiceovers/{voiceover_id}/activate


文件存储集成

与 FileStorageService 的集成

分镜资源服务通过 FileStorageService 实现文件去重和引用计数管理:

1. 文件上传流程

# AI 服务生成文件后调用
async def create_image(self, user_id, storyboard_id, image_data):
    # 1. 检查文件是否已存在(通过 checksum)
    existing_file = await self.file_storage.get_by_checksum(image_data.checksum)
    
    if existing_file:
        # 2a. 文件已存在,增加引用计数
        await self.file_storage.increase_reference_count(image_data.checksum)
        file_url = existing_file.file_url
    else:
        # 2b. 文件不存在,使用 AI 服务已上传的 URL
        file_url = image_data.url
    
    # 3. 创建分镜图片记录
    image = StoryboardImage(
        url=file_url,
        checksum=image_data.checksum,
        storage_provider=image_data.storage_provider,
        storage_path=image_data.storage_path,
        # ...
    )
    return await self.repository.create_image(image)

2. 文件删除流程

async def delete_image(self, user_id, image_id):
    image = await self.repository.get_image_by_id(image_id)
    
    # 1. 删除数据库记录
    await self.repository.delete_image(image_id)
    
    # 2. 减少文件引用计数
    # 如果引用计数为 0,FileStorageService 会自动删除文件
    await self.file_storage.decrease_reference_count(image.checksum)

3. 去重机制

  • 所有分镜资源(图片、视频、配音)都通过 checksum 字段关联 file_checksums
  • 相同文件只存储一次,节省存储空间
  • 引用计数管理确保文件安全删除

4. 存储信息

分镜资源表直接存储文件信息:

  • url:文件访问 URL
  • checksum:SHA256 校验和(必填)
  • storage_provider:存储提供商(minio/s3/oss)
  • storage_path:对象存储中的路径

优势

  • 不依赖 attachments 表,语义更清晰
  • 通过 checksum 关联 file_checksums 表实现去重
  • 支持跨表去重(与 attachmentsproject_resources 等共享去重机制)

数据库迁移脚本

Alembic 迁移示例

"""添加分镜资源表

Revision ID: 20260129_1800_add_storyboard_resources
Revises: <previous_revision>
Create Date: 2026-01-29 18:00:00.000000

"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql

# revision identifiers, used by Alembic.
revision = '20260129_1800_add_storyboard_resources'
down_revision = '<previous_revision>'
branch_labels = None
depends_on = None


def upgrade() -> None:
    # 1. 创建 storyboard_images 表
    op.create_table(
        'storyboard_images',
        sa.Column('image_id', postgresql.UUID(as_uuid=True), nullable=False),
        sa.Column('storyboard_id', postgresql.UUID(as_uuid=True), nullable=False),
        sa.Column('url', sa.Text(), nullable=False),
        sa.Column('status', sa.SmallInteger(), nullable=False),
        sa.Column('is_active', sa.Boolean(), server_default='false'),
        sa.Column('version', sa.Integer(), nullable=False, server_default='1'),
        sa.Column('width', sa.Integer(), nullable=True),
        sa.Column('height', sa.Integer(), nullable=True),
        sa.Column('file_size', sa.BigInteger(), nullable=True),
        sa.Column('format', sa.Text(), nullable=True),
        sa.Column('checksum', sa.Text(), nullable=False),
        sa.Column('storage_provider', sa.Text(), nullable=True),
        sa.Column('storage_path', sa.Text(), nullable=False),
        sa.Column('ai_model', sa.Text(), nullable=True),
        sa.Column('ai_prompt', sa.Text(), nullable=True),
        sa.Column('ai_params', postgresql.JSONB(astext_type=sa.Text()), server_default='{}'),
        sa.Column('error_message', sa.Text(), nullable=True),
        sa.Column('created_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False),
        sa.Column('completed_at', sa.TIMESTAMP(timezone=True), nullable=True),
        sa.PrimaryKeyConstraint('image_id'),
        sa.UniqueConstraint('storyboard_id', 'is_active', name='storyboard_images_active_unique', postgresql_where=sa.text('is_active = true'))
    )
    
    # 表注释
    op.execute("COMMENT ON TABLE storyboard_images IS '分镜图片表 - 存储AI生成的分镜画面,支持多版本管理'")
    
    # 列注释
    op.execute("COMMENT ON COLUMN storyboard_images.image_id IS '图片ID(主键)'")
    op.execute("COMMENT ON COLUMN storyboard_images.storyboard_id IS '所属分镜ID'")
    op.execute("COMMENT ON COLUMN storyboard_images.status IS '生成状态: 0=pending, 1=processing, 2=completed, 3=failed'")
    op.execute("COMMENT ON COLUMN storyboard_images.is_active IS '是否为当前激活版本(每个分镜只能有一个激活图片)'")
    op.execute("COMMENT ON COLUMN storyboard_images.checksum IS 'SHA256校验和,用于文件去重'")
    
    # 索引
    op.create_index('idx_storyboard_images_storyboard_id', 'storyboard_images', ['storyboard_id'])
    op.create_index('idx_storyboard_images_status', 'storyboard_images', ['status'])
    op.create_index('idx_storyboard_images_checksum', 'storyboard_images', ['checksum'])
    
    # 2. 创建 storyboard_videos 表
    op.create_table(
        'storyboard_videos',
        sa.Column('video_id', postgresql.UUID(as_uuid=True), nullable=False),
        sa.Column('storyboard_id', postgresql.UUID(as_uuid=True), nullable=False),
        sa.Column('url', sa.Text(), nullable=False),
        sa.Column('status', sa.SmallInteger(), nullable=False),
        sa.Column('is_active', sa.Boolean(), server_default='false'),
        sa.Column('version', sa.Integer(), nullable=False, server_default='1'),
        sa.Column('duration', sa.Numeric(10, 3), nullable=True),
        sa.Column('resolution', sa.Text(), nullable=True),
        sa.Column('frame_rate', sa.Integer(), nullable=True),
        sa.Column('file_size', sa.BigInteger(), nullable=True),
        sa.Column('format', sa.Text(), nullable=True),
        sa.Column('checksum', sa.Text(), nullable=False),
        sa.Column('storage_provider', sa.Text(), nullable=True),
        sa.Column('storage_path', sa.Text(), nullable=False),
        sa.Column('ai_model', sa.Text(), nullable=True),
        sa.Column('ai_params', postgresql.JSONB(astext_type=sa.Text()), server_default='{}'),
        sa.Column('error_message', sa.Text(), nullable=True),
        sa.Column('created_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False),
        sa.Column('started_at', sa.TIMESTAMP(timezone=True), nullable=True),
        sa.Column('completed_at', sa.TIMESTAMP(timezone=True), nullable=True),
        sa.PrimaryKeyConstraint('video_id'),
        sa.UniqueConstraint('storyboard_id', 'is_active', name='storyboard_videos_active_unique', postgresql_where=sa.text('is_active = true'))
    )
    
    op.execute("COMMENT ON TABLE storyboard_videos IS '分镜视频表 - 存储AI生成的分镜视频,支持多版本管理'")
    op.create_index('idx_storyboard_videos_storyboard_id', 'storyboard_videos', ['storyboard_id'])
    op.create_index('idx_storyboard_videos_status', 'storyboard_videos', ['status'])
    op.create_index('idx_storyboard_videos_checksum', 'storyboard_videos', ['checksum'])
    
    # 3. 创建 storyboard_dialogues 表
    op.create_table(
        'storyboard_dialogues',
        sa.Column('dialogue_id', postgresql.UUID(as_uuid=True), nullable=False),
        sa.Column('storyboard_id', postgresql.UUID(as_uuid=True), nullable=False),
        sa.Column('character_id', postgresql.UUID(as_uuid=True), nullable=True),
        sa.Column('character_name', sa.Text(), nullable=True),
        sa.Column('content', sa.Text(), nullable=False),
        sa.Column('sequence_order', sa.Integer(), nullable=False),
        sa.Column('start_time', sa.Numeric(10, 3), nullable=True),
        sa.Column('duration', sa.Numeric(10, 3), nullable=True),
        sa.Column('emotion', sa.Text(), nullable=True),
        sa.Column('notes', sa.Text(), nullable=True),
        sa.Column('created_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False),
        sa.Column('updated_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False),
        sa.PrimaryKeyConstraint('dialogue_id'),
        sa.UniqueConstraint('storyboard_id', 'sequence_order', name='storyboard_dialogues_order_unique')
    )
    
    op.execute("COMMENT ON TABLE storyboard_dialogues IS '分镜对白表 - 存储分镜的台词文本'")
    op.create_index('idx_storyboard_dialogues_storyboard_id', 'storyboard_dialogues', ['storyboard_id'])
    op.create_index('idx_storyboard_dialogues_character_id', 'storyboard_dialogues', ['character_id'], postgresql_where=sa.text('character_id IS NOT NULL'))
    
    # 4. 创建 storyboard_voiceovers 表
    op.create_table(
        'storyboard_voiceovers',
        sa.Column('voiceover_id', postgresql.UUID(as_uuid=True), nullable=False),
        sa.Column('dialogue_id', postgresql.UUID(as_uuid=True), nullable=False),
        sa.Column('storyboard_id', postgresql.UUID(as_uuid=True), nullable=False),
        sa.Column('audio_url', sa.Text(), nullable=False),
        sa.Column('status', sa.SmallInteger(), nullable=False),
        sa.Column('is_active', sa.Boolean(), server_default='false'),
        sa.Column('voice_id', sa.Text(), nullable=False),
        sa.Column('voice_name', sa.Text(), nullable=True),
        sa.Column('speed', sa.Numeric(3, 2), server_default='1.0'),
        sa.Column('volume', sa.Numeric(3, 2), server_default='1.0'),
        sa.Column('pitch', sa.Numeric(3, 2), server_default='1.0'),
        sa.Column('duration', sa.Numeric(10, 3), nullable=True),
        sa.Column('file_size', sa.BigInteger(), nullable=True),
        sa.Column('format', sa.Text(), nullable=True),
        sa.Column('checksum', sa.Text(), nullable=False),
        sa.Column('storage_provider', sa.Text(), nullable=True),
        sa.Column('storage_path', sa.Text(), nullable=False),
        sa.Column('error_message', sa.Text(), nullable=True),
        sa.Column('created_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False),
        sa.Column('completed_at', sa.TIMESTAMP(timezone=True), nullable=True),
        sa.PrimaryKeyConstraint('voiceover_id'),
        sa.UniqueConstraint('dialogue_id', 'is_active', name='storyboard_voiceovers_active_unique', postgresql_where=sa.text('is_active = true'))
    )
    
    op.execute("COMMENT ON TABLE storyboard_voiceovers IS '分镜配音表 - 存储对白的TTS音频,支持多版本管理'")
    op.create_index('idx_storyboard_voiceovers_dialogue_id', 'storyboard_voiceovers', ['dialogue_id'])
    op.create_index('idx_storyboard_voiceovers_storyboard_id', 'storyboard_voiceovers', ['storyboard_id'])
    op.create_index('idx_storyboard_voiceovers_status', 'storyboard_voiceovers', ['status'])
    op.create_index('idx_storyboard_voiceovers_checksum', 'storyboard_voiceovers', ['checksum'])


def downgrade() -> None:
    op.drop_table('storyboard_voiceovers')
    op.drop_table('storyboard_dialogues')
    op.drop_table('storyboard_videos')
    op.drop_table('storyboard_images')

相关文档


变更记录

v1.3 (2026-02-03)

  • 修复 UUID v7 生成方式(移除数据库 DEFAULT,改为应用层生成)
  • 修复日志格式化(统一使用 %-formatting)
  • 修复异常日志(添加 exc_info=True)
  • 修复 API 响应格式(统一使用 ApiResponse 格式)
  • 移除数据库注释(将在迁移脚本中添加)
  • 符合 jointo-tech-stack v1.0 规范

v1.2 (2026-01-29)

  • 添加完整的日志记录(符合 logging.md 规范)
  • 添加数据库表和列注释
  • 统一 API 响应格式(符合 api-design.md 规范)
  • 添加 Alembic 迁移脚本示例
  • 添加异常处理和日志记录
  • 统一文档版本号

v1.1 (2026-01-22)

  • 集成 FileStorageService 实现文件去重
  • 补充文件存储实现细节
  • 更新数据库设计:
    • checksum 改为必填
    • 新增 storage_provider 和 storage_path
    • status 字段改为 SMALLINT 类型(使用代码枚举)
  • 更新服务实现(文件上传和删除逻辑)
  • 新增"文件存储集成"章节
  • 新增 ResourceStatus 枚举定义

v1.0 (2026-01-22)

  • 初始版本
  • 定义分镜资源服务架构
  • 实现分镜图片、视频、对白、配音管理
  • 支持版本管理和激活切换
  • 与项目素材服务区分

文档版本:v1.2
最后更新:2026-01-29