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.
 

79 KiB

分镜管理服务

文档版本:v2.0
最后更新:2025-01-27


目录

  1. 服务概述
  2. 核心功能
  3. 数据库设计
  4. 服务实现
  5. API 接口
  6. 数据模型

服务概述

分镜管理服务负责处理分镜的创建、查询、更新、排序等业务逻辑,是视频创作的核心模块。

职责

  • 分镜 CRUD 操作
  • 分镜排序管理
  • 分镜资源关联(角色、场景、道具)
  • 分镜时间轴管理
  • 分镜缩略图生成

核心功能

1. 分镜创建

  • 创建分镜
  • 镜号管理
    • 使用 order_index 作为镜号(1, 2, 3...)
    • 创建时自动分配下一个序号
    • 前端可格式化显示(如 #1, 001
  • 设置标题、描述、对白
  • 设置景别、运镜
  • 设置预估时长
  • 自动分配顺序索引
  • 设置时间轴位置

2. 分镜查询

  • 获取项目的分镜列表
  • 按镜号/顺序排序
  • 按景别、运镜筛选
  • 支持分页
  • 包含资源信息

3. 分镜排序

  • 调整分镜顺序
  • 批量重排序
  • 自动更新镜号
    • 调整顺序时,自动更新 order_index
    • order_index 即为镜号(1, 2, 3...)
    • 始终保持连续

4. 影视专业字段

  • 景别管理(大远景/远景/全景/中景/近景/特写)
  • 运镜管理(固定/摇镜/俯仰/移动/跟踪/变焦/升降/手持)
  • 拍摄描述:景深、运镜方式、镜头语言、拍摄技巧等专业描述
  • 对白/台词记录
  • 预估时长 vs 实际时长对比
  • 扩展字段(灯光、天气、道具、备注等)

5. 资源关联

  • 关联角色
  • 关联场景
  • 关联道具
  • 支持多个资源

6. 时间轴管理

  • 设置开始时间
  • 设置结束时间
  • 计算时长
  • 时间轴冲突检测

数据库设计

storyboards 表结构

-- 创建分镜表(应用层生成 UUID v7,无物理外键约束)
CREATE TABLE storyboards (
    storyboard_id UUID PRIMARY KEY,  -- 应用层生成 UUID v7
    project_id UUID NOT NULL,        -- 应用层验证引用完整性
    
    -- 基本信息
    title TEXT NOT NULL,
    description TEXT NOT NULL,
    shooting_description TEXT,
    
    -- 影视专业字段
    shot_size SMALLINT CHECK (shot_size BETWEEN 1 AND 8),
    camera_movement SMALLINT CHECK (camera_movement BETWEEN 1 AND 9),
    
    -- 时长管理
    estimated_duration NUMERIC(10, 3),
    actual_duration NUMERIC(10, 3),
    
    -- 时间轴定位(用于视频编辑)
    start_time NUMERIC(10, 3) NOT NULL DEFAULT 0,
    end_time NUMERIC(10, 3) NOT NULL,
    
    -- 缩略图
    thumbnail_url TEXT,
    thumbnail_id UUID,  -- 应用层验证引用完整性
    
    -- 排序和转场
    order_index INTEGER NOT NULL,
    transition_type TEXT,
    transition_duration NUMERIC(5, 2),
    
    -- 扩展字段(灯光、道具、备注等)
    meta_data JSONB NOT NULL DEFAULT '{}',
    
    -- 审计字段(ADR 006:使用 TIMESTAMPTZ)
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    
    -- 约束
    CONSTRAINT storyboards_order_unique UNIQUE (project_id, order_index) DEFERRABLE INITIALLY DEFERRED,
    CONSTRAINT storyboards_time_check CHECK (end_time > start_time),
    CONSTRAINT storyboards_duration_check CHECK (
        (estimated_duration IS NULL OR estimated_duration > 0) AND
        (actual_duration IS NULL OR actual_duration > 0)
    )
);

-- 索引(必须创建,因为无物理外键约束)
CREATE INDEX idx_storyboards_project_id ON storyboards (project_id);
CREATE INDEX idx_storyboards_order ON storyboards (project_id, order_index);
CREATE INDEX idx_storyboards_shot_size ON storyboards (shot_size) WHERE shot_size IS NOT NULL;
CREATE INDEX idx_storyboards_camera_movement ON storyboards (camera_movement) WHERE camera_movement IS NOT NULL;
CREATE INDEX idx_storyboards_time_range ON storyboards USING GIST (numrange(start_time, end_time));
CREATE INDEX idx_storyboards_thumbnail_id ON storyboards (thumbnail_id) WHERE thumbnail_id IS NOT NULL;
CREATE INDEX idx_storyboards_meta_data_gin ON storyboards USING GIN (meta_data);

-- 全文搜索索引
CREATE INDEX idx_storyboards_title_trgm ON storyboards USING GIN (title gin_trgm_ops);
CREATE INDEX idx_storyboards_description_trgm ON storyboards USING GIN (description gin_trgm_ops);
CREATE INDEX idx_storyboards_shooting_description_trgm ON storyboards USING GIN (shooting_description gin_trgm_ops) WHERE shooting_description IS NOT NULL;

-- 触发器
CREATE TRIGGER update_storyboards_updated_at
    BEFORE UPDATE ON storyboards
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at_column();

注意:不再使用触发器自动生成镜号,改为应用层控制,提供更大的灵活性。

storyboard_items 表结构(统一关联表)

-- 创建分镜元素关联表(应用层生成 UUID v7,无物理外键约束)
CREATE TABLE storyboard_items (
    item_id UUID PRIMARY KEY,  -- 应用层生成 UUID v7
    storyboard_id UUID NOT NULL,  -- 应用层验证引用完整性
    
    -- 多态关联核心
    item_type SMALLINT NOT NULL,  -- 1=ElementTag(剧本元素标签), 2=Resource(项目素材)
    
    -- 关联字段(根据 item_type 使用不同字段)
    element_tag_id UUID,  -- item_type=1 时使用,指向 screenplay_element_tags.tag_id
    resource_id UUID,     -- item_type=2 时使用,指向 project_resources.project_resource_id
    
    -- 冗余字段(优化读性能,避免 JOIN)
    element_name TEXT,        -- 元素名称(如"孙悟空"、"咖啡厅")
    tag_label TEXT,           -- 标签名称(如"少年"、"白天")
    cover_url TEXT,           -- 封面 URL
    
    -- 核心优势:关联属性(meta_data on Relation)
    is_visible BOOLEAN DEFAULT true,  -- 是否在画面内(画外音设为 false)
    spatial_position TEXT,  -- 画面位置:left/center/right/background/foreground
    action_description TEXT,  -- 动作描述:如"大笑"、"奔跑"
    
    -- 排序与层级
    display_order INTEGER DEFAULT 0,  -- 显示顺序
    z_index INTEGER DEFAULT 0,  -- 视觉层级(用于合成时的图层顺序)
    
    -- 扩展字段
    meta_data JSONB DEFAULT '{}',  -- 更灵活的扩展属性
    
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    
    -- 确保只有一个关联字段非空
    CONSTRAINT storyboard_items_one_fk_check CHECK (
        (item_type = 1 AND element_tag_id IS NOT NULL AND resource_id IS NULL) OR
        (item_type = 2 AND resource_id IS NOT NULL AND element_tag_id IS NULL)
    ),
    
    -- 唯一约束(防止重复关联)
    CONSTRAINT storyboard_items_tag_unique UNIQUE (storyboard_id, element_tag_id) 
        DEFERRABLE INITIALLY DEFERRED,
    CONSTRAINT storyboard_items_resource_unique UNIQUE (storyboard_id, resource_id) 
        DEFERRABLE INITIALLY DEFERRED
);

-- 索引(必须创建,因为无物理外键约束)
CREATE INDEX idx_storyboard_items_storyboard_id ON storyboard_items (storyboard_id);
CREATE INDEX idx_storyboard_items_element_tag ON storyboard_items (element_tag_id) 
    WHERE element_tag_id IS NOT NULL;
CREATE INDEX idx_storyboard_items_resource ON storyboard_items (resource_id) 
    WHERE resource_id IS NOT NULL;
CREATE INDEX idx_storyboard_items_order ON storyboard_items (storyboard_id, display_order);
CREATE INDEX idx_storyboard_items_type ON storyboard_items (item_type);

设计说明

  1. 主键类型:使用 UUID v7(应用层生成,通过 generate_uuid 函数)

  2. 引用完整性保证

    • ⚠️ 禁止物理外键约束:数据库层不创建 FOREIGN KEY
    • 应用层验证:Service 层负责验证所有引用关系
    • 必须创建索引:所有关联字段创建索引以保证查询性能
  3. 关联设计理念

    • 剧本元素(角色/场景/道具):关联到 screenplay_element_tags(标签表)
      • 原因:角色有多个变体(少年/青年/中年),场景有多个状态(白天/夜晚),道具有多个形态(新/旧)
      • 分镜需要指定使用哪个标签(变体)
      • 通过标签可以反查到元素本身(tag.element_id)
    • 项目素材:直接关联到 project_resources
      • 原因:实拍素材、音频、视频等不需要变体概念
      • 直接使用素材 ID 即可
  4. 镜号管理

    • 使用 order_index 作为镜号
    • 创建时自动分配下一个序号(1, 2, 3...)
    • 删除时,后续分镜的 order_index 自动 -1
    • 排序时,批量更新 order_index
    • 前端显示时可格式化(如 #1, 001
    • 极简设计:无需单独维护镜号字段
  5. 影视专业字段

    • 景别(shot_size):使用 SMALLINT 存储(1-8),代码层使用 IntEnum(8种标准景别,基于国际影视行业标准)
      • 1=extreme_wide_shot(大远景), 2=wide_shot(远景), 3=full_shot(全景), 4=medium_shot(中景), 5=medium_close_up(中近景), 6=close_up(特写), 7=extreme_close_up(大特写), 8=over_shoulder(过肩镜头)
    • 运镜(camera_movement):使用 SMALLINT 存储(1-9),代码层使用 IntEnum(9种标准运镜,基于国际影视行业标准)
      • 1=static(固定), 2=pan(摇镜), 3=tilt(俯仰), 4=dolly(推拉), 5=zoom(变焦), 6=tracking(跟踪), 7=arc(环绕), 8=crane(升降), 9=handheld(手持)
    • 对白(dialogue):TEXT 类型,支持全文搜索
    • 时长管理
      • estimated_duration:预估时长(制作计划)
      • actual_duration:实际时长(制作完成后填写)
      • 两者可独立存在,便于对比分析
  6. 时间轴定位

    • start_time/end_time:用于视频编辑时间轴定位
    • estimated_duration/actual_duration 区分
    • 支持时间范围查询(GiST 索引)
  7. 扩展字段(meta_data)

    • 灯光(lighting)
    • 天气(weather)
    • 时段(time_of_day)
    • 道具列表(props)
    • 备注(notes)
    • 参考图片(reference_images)
    • 使用 GIN 索引支持 JSONB 查询
  8. 缩略图管理

    • thumbnail_url:直接存储 URL(兼容旧数据)
    • thumbnail_id:关联 attachments 表(推荐方式,一对一,应用层验证)
  9. 转场信息

    • transition_type:转场类型(淡入淡出、切换等)
    • transition_duration:转场时长(秒)
  10. 顺序管理

    • 使用 DEFERRABLE INITIALLY DEFERRED 处理顺序调整时的唯一约束冲突
    • 支持批量重排序
    • 镜号自动同步更新
  11. 索引策略

  • B-tree 索引:project_id, order_index
  • 部分索引:shot_size, camera_movement(仅非空值)
  • GiST 索引:时间范围查询
  • GIN 索引:meta_data, 全文搜索(title, dialogue)
  1. 元素关联(storyboard_items 表)
  • 统一关联表:管理分镜与剧本元素标签、项目素材的关联
  • 两种关联类型
    • item_type=1:剧本元素标签(角色/场景/道具的变体)
    • item_type=2:项目素材(实拍素材、音频、视频等)
  • 关联字段
    • element_tag_id:指向 screenplay_element_tags.tag_id(剧本元素标签)
    • resource_id:指向 project_resources.project_resource_id(项目素材)
  • 冗余字段element_name, tag_label, cover_url 避免 JOIN,极大提升读性能
  • 关联属性action_description(动作)、spatial_position(位置)、is_visible(可见性)
  • 排序与层级display_order(显示顺序)、z_index(视觉层级)
  • CHECK 约束:确保只有一个关联字段非空
  • 唯一约束:防止重复关联同一标签或素材
  1. 数据完整性

    • CHECK 约束:时间范围、时长正值
    • UNIQUE 约束:顺序索引、元素关联去重
    • 触发器:自动更新 updated_at
  2. 审计追踪:created_at、updated_at 自动维护

枚举值映射表

storyboard_items.item_type

数值 说明 关联字段 关联表
1 剧本元素标签 element_tag_id screenplay_element_tags
2 项目素材 resource_id project_resources

设计理念

  • 剧本元素(角色/场景/道具):通过标签关联,支持多变体
    • 例:角色"孙悟空"有"少年"、"青年"、"中年"三个标签
    • 分镜指定使用"少年"标签,即 element_tag_id 指向"孙悟空-少年"的标签 ID
  • 项目素材:直接关联素材 ID,无需变体概念

shot_size(景别)

枚举类型 数值 字符串值 说明
shot_size 1 extreme_wide_shot 大远景(建立场景)
2 wide_shot 远景(展示环境)
3 full_shot 全景(全身)
4 medium_shot 中景(腰部以上)
5 medium_close_up 中近景(胸部/肩部以上)
6 close_up 特写(面部)
7 extreme_close_up 大特写(局部细节)
8 over_shoulder 过肩镜头(对话场景)
camera_movement 1 static 固定
2 pan 摇镜(水平旋转)
3 tilt 俯仰(垂直旋转)
4 dolly 推拉(前后移动)
5 zoom 变焦(焦距变化)
6 tracking 跟踪(跟随主体)
7 arc 环绕(圆周运动)
8 crane 升降(垂直移动/摇臂)
9 handheld 手持(纪录片风格)

screenplay_element_tags.element_type(剧本元素类型)

数值 说明 关联表
1 角色 screenplay_characters
2 场景 screenplay_locations
3 道具 screenplay_props

说明storyboard_items 通过 element_tag_id 关联到标签,标签的 element_type 字段标识是角色/场景/道具。


分镜元素关联架构

Storyboard Service 通过统一的 storyboard_items 关联表管理分镜与"人、地、物、素材"的关联关系。

设计理念

统一关联表设计

  • 废弃 UUID[] 数组字段(无法存储关联元数据)
  • 废弃独立的 storyboard_resources 表(架构不一致)
  • 采用统一的 storyboard_items 表(符合关系型数据库第三范式)

核心优势

  1. 元数据能力:可存储关联属性(动作、位置、状态等),为 AI 视频生成提供精准 Prompt
  2. 统一接口:前端只需调用一个 API 获取所有元素
  3. 易于管理:支持排序、层级、可见性等复杂业务需求
  4. 高性能:通过冗余字段避免 JOIN,读取性能极佳

关联表设计

-- storyboard_items 表(分镜元素关联表)
CREATE TABLE storyboard_items (
    item_id UUID PRIMARY KEY,
    storyboard_id UUID NOT NULL,
    
    -- 多态关联核心
    item_type SMALLINT NOT NULL,  -- 1=Character, 2=Location, 3=Prop, 4=Resource
    target_id UUID NOT NULL,
    
    -- 冗余字段(优化读性能,避免 JOIN)
    target_name TEXT,
    target_cover_url TEXT,
    
    -- 核心优势:关联属性(meta_data on Relation)
    is_visible BOOLEAN DEFAULT true,  -- 是否在画面内(画外音设为 false)
    spatial_position TEXT,  -- 画面位置:left/center/right/background/foreground
    action_description TEXT,  -- 动作描述:如"大笑"、"奔跑"
    
    -- 标签关联(可选)
    tag_id UUID,  -- 关联的标签 ID(如角色的年龄段标签)
    
    -- 排序与层级
    display_order INTEGER DEFAULT 0,  -- 显示顺序
    z_index INTEGER DEFAULT 0,  -- 视觉层级(用于合成时的图层顺序)
    
    -- 扩展字段
    meta_data JSONB DEFAULT '{}',  -- 更灵活的扩展属性
    
    created_at TIMESTAMPTZ DEFAULT now()
);

-- 索引
CREATE INDEX idx_sb_items_storyboard ON storyboard_items(storyboard_id);
CREATE INDEX idx_sb_items_target ON storyboard_items(item_type, target_id);
CREATE INDEX idx_sb_items_order ON storyboard_items(storyboard_id, display_order);
CREATE INDEX idx_sb_items_tag ON storyboard_items(tag_id) WHERE tag_id IS NOT NULL;

-- 列注释
COMMENT ON TABLE storyboard_items IS '分镜元素关联表 - 统一管理分镜与角色、场景、道具、素材的关联';
COMMENT ON COLUMN storyboard_items.item_id IS '关联记录唯一标识(UUID v7)';
COMMENT ON COLUMN storyboard_items.storyboard_id IS '所属分镜ID';
COMMENT ON COLUMN storyboard_items.item_type IS '元素类型:1=Character(角色), 2=Location(场景), 3=Prop(道具), 4=Resource(素材)';
COMMENT ON COLUMN storyboard_items.target_id IS '目标元素ID(可能指向不同表)';
COMMENT ON COLUMN storyboard_items.target_name IS '目标元素名称(冗余存储,避免JOIN)';
COMMENT ON COLUMN storyboard_items.target_cover_url IS '目标元素封面URL(冗余存储)';
COMMENT ON COLUMN storyboard_items.is_visible IS '是否在画面内(画外音/旁白设为false)';
COMMENT ON COLUMN storyboard_items.spatial_position IS '画面位置:left/center/right/background/foreground';
COMMENT ON COLUMN storyboard_items.action_description IS '动作描述(如"大笑"、"奔跑"、"挥舞")';
COMMENT ON COLUMN storyboard_items.tag_id IS '关联的标签ID(如角色的年龄段标签、场景的时代标签)';
COMMENT ON COLUMN storyboard_items.display_order IS '显示顺序(用于前端列表排序)';
COMMENT ON COLUMN storyboard_items.z_index IS '视觉层级(用于视频合成时的图层顺序)';
COMMENT ON COLUMN storyboard_items.meta_data IS '扩展属性(JSONB格式)';
COMMENT ON COLUMN storyboard_items.created_at IS '创建时间';

枚举值定义

item_type 数值 说明 关联字段 关联表
ElementTag 1 剧本元素标签 element_tag_id screenplay_element_tags
Resource 2 项目素材 resource_id project_resources

重要说明

  • 剧本元素(角色/场景/道具)统一通过标签关联
    • 不再区分 Character/Location/Prop 三种类型
    • 统一使用 item_type=1(ElementTag)
    • 通过 element_tag_id 关联到 screenplay_element_tags
    • 标签表的 element_type 字段区分是角色(1)/场景(2)/道具(3)
  • 项目素材直接关联
    • 使用 item_type=2(Resource)
    • 通过 resource_id 关联到 project_resources

AI 解析剧本后的自动关联

当 AI 解析剧本完成后,Celery Worker 调用 Storyboard Service 创建分镜并自动建立关联:

async def create_storyboards_from_ai(
    self,
    project_id: UUID,
    screenplay_id: UUID,
    storyboards_data: List[Dict[str, Any]],
    tag_id_maps: Dict[str, UUID]
) -> List[UUID]:
    """
    从 AI 解析结果创建分镜并自动关联元素标签
    
    Args:
        project_id: 项目 ID
        screenplay_id: 剧本 ID
        storyboards_data: AI 返回的分镜数据数组
        tag_id_maps: 标签 ID 映射(由 ScreenplayTagService.store_tags() 返回)
            {
                'character_tags': {
                    '孙悟空-少年': UUID('019d-...'),
                    '孙悟空-青年': UUID('019d-...')
                },
                'location_tags': {
                    '咖啡厅-白天': UUID('019d-...'),
                    '咖啡厅-夜晚': UUID('019d-...')
                },
                'prop_tags': {
                    '金箍棒-新': UUID('019d-...'),
                    '金箍棒-旧': UUID('019d-...')
                }
            }
    
    Returns:
        创建的分镜 ID 列表
    """
    storyboard_ids = []
    
    for storyboard_data in storyboards_data:
        # 1. 创建分镜记录
        storyboard = Storyboard(
            project_id=project_id,
            screenplay_id=screenplay_id,
            title=storyboard_data['title'],
            description=storyboard_data['description'],
            shooting_description=storyboard_data.get('shooting_description'),
            shot_size=storyboard_data.get('shot_size'),
            camera_movement=storyboard_data.get('camera_movement'),
            estimated_duration=storyboard_data.get('estimated_duration'),
            order_index=storyboard_data['order_index'],
            start_time=storyboard_data.get('start_time', 0),
            end_time=storyboard_data.get('end_time'),
            meta_data=storyboard_data.get('meta_data', {})
        )
        
        created_storyboard = await self.repository.create(storyboard)
        storyboard_id = created_storyboard.storyboard_id
        
        # 2. 创建元素关联(角色标签)
        for char_data in storyboard_data.get('characters', []):
            # 构建映射键:"{元素名}-{标签名}"
            map_key = f"{char_data['name']}-{char_data['tag_label']}"
            element_tag_id = tag_id_maps.get('character_tags', {}).get(map_key)
            
            if element_tag_id:
                # 获取标签信息(用于冗余字段)
                tag = await self._get_tag_info(element_tag_id)
                
                # 创建关联记录
                await self.repository.create_item(StoryboardItem(
                    storyboard_id=storyboard_id,
                    item_type=ItemType.ELEMENT_TAG,
                    element_tag_id=element_tag_id,
                    element_name=char_data['name'],
                    tag_label=tag.tag_label,
                    cover_url=tag.cover_url,
                    action_description=char_data.get('action'),
                    spatial_position=char_data.get('position'),
                    is_visible=char_data.get('is_visible', True),
                    display_order=char_data.get('order', 0)
                ))
        
        # 3. 创建元素关联(场景标签)
        for location_data in storyboard_data.get('locations', []):
            # 构建映射键:"{元素名}-{标签名}"
            map_key = f"{location_data['name']}-{location_data['tag_label']}"
            element_tag_id = tag_id_maps.get('location_tags', {}).get(map_key)
            
            if element_tag_id:
                tag = await self._get_tag_info(element_tag_id)
                
                await self.repository.create_item(StoryboardItem(
                    storyboard_id=storyboard_id,
                    item_type=ItemType.ELEMENT_TAG,
                    element_tag_id=element_tag_id,
                    element_name=location_data['name'],
                    tag_label=tag.tag_label,
                    cover_url=tag.cover_url,
                    display_order=location_data.get('order', 0)
                ))
        
        # 4. 创建元素关联(道具标签)
        for prop_data in storyboard_data.get('props', []):
            # 构建映射键:"{元素名}-{标签名}"
            map_key = f"{prop_data['name']}-{prop_data['tag_label']}"
            element_tag_id = tag_id_maps.get('prop_tags', {}).get(map_key)
            
            if element_tag_id:
                tag = await self._get_tag_info(element_tag_id)
                
                await self.repository.create_item(StoryboardItem(
                    storyboard_id=storyboard_id,
                    item_type=ItemType.ELEMENT_TAG,
                    element_tag_id=element_tag_id,
                    element_name=prop_data['name'],
                    tag_label=tag.tag_label,
                    cover_url=tag.cover_url,
                    action_description=prop_data.get('action'),
                    spatial_position=prop_data.get('position'),
                    display_order=prop_data.get('order', 0)
                ))
        
        storyboard_ids.append(storyboard_id)
    
    return storyboard_ids

输入数据示例

AI 返回的分镜数据结构:

{
  "title": "开场",
  "description": "主角在咖啡厅内,阳光从窗户洒进来",
  "shooting_description": "大景深,前景虚化,摇臂缓慢推进,焦点从背景转移到主角面部",
  "dialogue": "张三:你好,世界!",
  "characters": [
    {
      "name": "张三",
      "tag_label": "少年",
      "action": "大笑",
      "position": "center",
      "is_visible": true,
      "order": 0
    },
    {
      "name": "李四",
      "tag_label": "成年",
      "action": "站立",
      "position": "left",
      "is_visible": true,
      "order": 1
    }
  ],
  "locations": [
    {
      "name": "咖啡厅",
      "tag_label": "白天",
      "order": 0
    }
  ],
  "props": [
    {
      "name": "笔记本电脑",
      "position": "foreground",
      "order": 0
    }
  ]
}

处理结果

创建 1 个分镜记录 + 4 条 storyboard_items 关联记录:

item_type element_tag_id element_name tag_label action_description spatial_position display_order
1 (ElementTag) 孙悟空-少年 的 tag_id 孙悟空 少年 大笑 center 0
1 (ElementTag) 李四-成年 的 tag_id 李四 成年 站立 left 1
1 (ElementTag) 咖啡厅-白天 的 tag_id 咖啡厅 白天 NULL NULL 0
1 (ElementTag) 笔记本电脑-新 的 tag_id 笔记本电脑 NULL foreground 0

映射逻辑说明

  • AI 返回:{"name": "孙悟空", "tag_label": "少年"}
  • 构建映射键:map_key = "孙悟空-少年"
  • 查找 tag_id:element_tag_id = tag_id_maps['character_tags']['孙悟空-少年']
  • 获得的 element_tag_id 即为 screenplay_element_tags 表中的 tag_id

价值体现

  • "大笑"、"站立"等动作信息被完整保存
  • "center"、"left"等位置信息被完整保存
  • 标签关联(少年、成年、白天、新)被正确建立
  • 通过标签可以反查到元素本身(tag.element_id)
  • 为 AI 视频生成提供了精准的 Prompt 来源

查询分镜元素

查询分镜时,自动加载关联的所有元素:

async def get_storyboard_with_elements(
    self,
    storyboard_id: UUID
) -> Dict[str, Any]:
    """获取分镜及其关联的所有元素"""
    storyboard = await self.repository.get_by_id(storyboard_id)
    if not storyboard:
        raise NotFoundError("分镜不存在")
    
    # 加载所有关联元素(统一查询)
    items = await self.repository.get_items_by_storyboard(storyboard_id)
    
    # 按类型分组
    characters = []
    locations = []
    props = []
    resources = []
    
    for item in items:
        item_data = {
            'item_id': str(item.item_id),
            'target_id': str(item.target_id),
            'target_name': item.target_name,
            'target_cover_url': item.target_cover_url,
            'action_description': item.action_description,
            'spatial_position': item.spatial_position,
            'is_visible': item.is_visible,
            'display_order': item.display_order,
            'z_index': item.z_index
        }
        
        # 加载标签信息(如果有)
        if item.tag_id:
            tag = await self._get_tag_info(item.tag_id)
            if tag:
                item_data['tag'] = {
                    'tag_id': str(tag.tag_id),
                    'tag_label': tag.tag_label,
                    'description': tag.description
                }
        
        # 按类型分组
        if item.item_type == ItemType.CHARACTER:
            characters.append(item_data)
        elif item.item_type == ItemType.LOCATION:
            locations.append(item_data)
        elif item.item_type == ItemType.PROP:
            props.append(item_data)
        elif item.item_type == ItemType.RESOURCE:
            resources.append(item_data)
    
    return {
        'storyboard_id': str(storyboard.storyboard_id),
        'title': storyboard.title,
        'description': storyboard.description,
        'shooting_description': storyboard.shooting_description,
        'shot_size': storyboard.shot_size,
        'camera_movement': storyboard.camera_movement,
        'estimated_duration': storyboard.estimated_duration,
        'characters': characters,
        'locations': locations,
        'props': props,
        'resources': resources,
        'meta_data': storyboard.meta_data
    }

SQL 查询示例

使用 PostgreSQL 查询分镜关联的元素信息:

-- 查询分镜关联的所有元素(统一查询)
SELECT 
    si.item_id,
    si.item_type,
    si.element_tag_id,
    si.resource_id,
    si.element_name,
    si.tag_label,
    si.cover_url,
    si.action_description,
    si.spatial_position,
    si.is_visible,
    si.display_order,
    si.z_index,
    -- 标签详细信息
    et.tag_id,
    et.element_type,
    et.element_id,
    et.description AS tag_description
FROM storyboard_items si
LEFT JOIN screenplay_element_tags et ON et.tag_id = si.element_tag_id
WHERE si.storyboard_id = '019d1234-5678-7abc-def0-123456789abc'
ORDER BY si.display_order;

查询结果示例

item_type element_name tag_label action_description spatial_position element_type
1 孙悟空 少年 大笑 center 1 (角色)
1 李四 成年 站立 left 1 (角色)
1 咖啡厅 白天 NULL NULL 2 (场景)
1 笔记本电脑 NULL foreground 3 (道具)

性能优势

  • 单次查询获取所有元素(无需多次 JOIN)
  • 冗余字段(element_name, tag_label, cover_url)避免额外查询
  • 索引优化(storyboard_id, display_order)保证查询效率
  • 通过标签的 element_type 区分角色/场景/道具

手动管理元素

用户也可以手动添加或移除分镜的关联元素:

async def add_element_to_storyboard(
    self,
    user_id: UUID,
    storyboard_id: UUID,
    item_type: int,  # 1=ElementTag, 2=Resource
    element_tag_id: Optional[UUID] = None,
    resource_id: Optional[UUID] = None,
    action_description: Optional[str] = None,
    spatial_position: Optional[str] = None,
    is_visible: bool = True
) -> Dict[str, Any]:
    """手动添加元素到分镜"""
    storyboard = await self.repository.get_by_id(storyboard_id)
    if not storyboard:
        raise NotFoundError("分镜不存在")
    
    # 检查权限
    await self._check_project_permission(user_id, storyboard.project_id, 'editor')
    
    # 验证元素类型
    if item_type not in [1, 2]:
        raise ValidationError(f"无效的元素类型: {item_type}")
    
    # 验证关联字段
    if item_type == 1 and not element_tag_id:
        raise ValidationError("剧本元素标签类型必须提供 element_tag_id")
    if item_type == 2 and not resource_id:
        raise ValidationError("项目素材类型必须提供 resource_id")
    
    # 获取元素信息(用于冗余字段)
    element_name = None
    tag_label = None
    cover_url = None
    
    if item_type == 1:
        # 获取标签信息
        tag = await self._get_tag_info(element_tag_id)
        if not tag:
            raise NotFoundError("标签不存在")
        
        # 验证标签归属的项目
        element = await self._get_element_by_tag(tag)
        if element.project_id != storyboard.project_id:
            raise ValidationError("标签不属于当前项目")
        
        element_name = element.name
        tag_label = tag.tag_label
        cover_url = tag.cover_url
        
        # 检查是否已存在
        existing = await self.repository.get_item_by_tag(storyboard_id, element_tag_id)
        if existing:
            raise ValidationError("该标签已添加到分镜")
    
    elif item_type == 2:
        # 获取项目素材信息
        resource = await self._get_resource_info(resource_id)
        if not resource:
            raise NotFoundError("项目素材不存在")
        
        if resource.project_id != storyboard.project_id:
            raise ValidationError("素材不属于当前项目")
        
        element_name = resource.name
        cover_url = resource.thumbnail_url
        
        # 检查是否已存在
        existing = await self.repository.get_item_by_resource(storyboard_id, resource_id)
        if existing:
            raise ValidationError("该素材已添加到分镜")
    
    # 获取下一个显示顺序
    max_order = await self.repository.get_max_display_order(storyboard_id)
    display_order = (max_order or 0) + 1
    
    # 创建关联记录
    item = StoryboardItem(
        storyboard_id=storyboard_id,
        item_type=item_type,
        element_tag_id=element_tag_id,
        resource_id=resource_id,
        element_name=element_name,
        tag_label=tag_label,
        cover_url=cover_url,
        action_description=action_description,
        spatial_position=spatial_position,
        is_visible=is_visible,
        display_order=display_order
    )
    
    created_item = await self.repository.create_item(item)
    
    return {
        'item_id': str(created_item.item_id),
        'item_type': created_item.item_type,
        'element_tag_id': str(created_item.element_tag_id) if created_item.element_tag_id else None,
        'resource_id': str(created_item.resource_id) if created_item.resource_id else None,
        'element_name': created_item.element_name,
        'tag_label': created_item.tag_label,
        'action_description': created_item.action_description,
        'spatial_position': created_item.spatial_position,
        'display_order': created_item.display_order
    }

async def remove_element_from_storyboard(
    self,
    user_id: UUID,
    item_id: UUID
) -> None:
    """从分镜移除元素"""
    item = await self.repository.get_item_by_id(item_id)
    if not item:
        raise NotFoundError("元素不存在")
    
    storyboard = await self.repository.get_by_id(item.storyboard_id)
    
    # 检查权限
    await self._check_project_permission(user_id, storyboard.project_id, 'editor')
    
    await self.repository.delete_item(item_id)

async def update_element_meta_data(
    self,
    user_id: UUID,
    item_id: UUID,
    action_description: Optional[str] = None,
    spatial_position: Optional[str] = None,
    is_visible: Optional[bool] = None,
    z_index: Optional[int] = None
) -> Dict[str, Any]:
    """更新元素的关联属性"""
    item = await self.repository.get_item_by_id(item_id)
    if not item:
        raise NotFoundError("元素不存在")
    
    storyboard = await self.repository.get_by_id(item.storyboard_id)
    
    # 检查权限
    await self._check_project_permission(user_id, storyboard.project_id, 'editor')
    
    # 更新字段
    update_data = {}
    if action_description is not None:
        update_data['action_description'] = action_description
    if spatial_position is not None:
        update_data['spatial_position'] = spatial_position
    if is_visible is not None:
        update_data['is_visible'] = is_visible
    if z_index is not None:
        update_data['z_index'] = z_index
    
    updated_item = await self.repository.update_item(item_id, update_data)
    
    return {
        'item_id': str(updated_item.item_id),
        'action_description': updated_item.action_description,
        'spatial_position': updated_item.spatial_position,
        'is_visible': updated_item.is_visible,
        'z_index': updated_item.z_index
    }

数据完整性保证

  1. 应用层验证:Service 层负责验证所有引用关系
  2. 冗余字段同步:当元素名称或封面变更时,异步更新 storyboard_items
  3. 级联删除:删除分镜时自动删除所有关联记录
  4. 去重检查:添加元素前检查是否已存在

相关文档


服务实现

StoryboardService 类

# app/services/storyboard_service.py
from typing import List, Optional, Dict, Any
from uuid import UUID
from decimal import Decimal
from sqlalchemy.orm import Session
from app.models.storyboard import Storyboard, StoryboardResource
from app.repositories.storyboard_repository import StoryboardRepository
from app.repositories.project_repository import ProjectRepository
from app.schemas.storyboard import StoryboardCreate, StoryboardUpdate, StoryboardReorder
from app.core.exceptions import NotFoundError, ValidationError, PermissionError

class StoryboardService:
    def __init__(self, db: Session):
        self.repository = StoryboardRepository(db)
        self.project_repository = ProjectRepository(db)
        self.db = db

    async def get_storyboards(
        self,
        user_id: UUID,
        project_id: UUID,
        page: int = 1,
        page_size: int = 50,
        include_items: bool = False
    ) -> Dict[str, Any]:
        """获取分镜列表"""
        # 检查项目权限
        await self._check_project_permission(user_id, project_id, 'viewer')

        storyboards = await self.repository.get_by_project(
            project_id, page, page_size
        )

        # 可选:加载关联元素信息
        if include_items:
            for storyboard in storyboards:
                items = await self.repository.get_items_by_storyboard(
                    storyboard.storyboard_id
                )
                storyboard.items = items

        total = await self.repository.count_by_project(project_id)

        return {
            'items': storyboards,
            'total': total,
            'page': page,
            'page_size': page_size,
            'total_pages': (total + page_size - 1) // page_size
        }

    async def get_storyboard(
        self,
        user_id: UUID,
        storyboard_id: UUID,
        include_items: bool = True
    ) -> Storyboard:
        """获取分镜详情"""
        storyboard = await self.repository.get_by_id(storyboard_id)
        if not storyboard:
            raise NotFoundError("分镜不存在")

        # 检查权限
        await self._check_project_permission(
            user_id, storyboard.project_id, 'viewer'
        )

        # 默认加载关联元素信息
        if include_items:
            items = await self.repository.get_items_by_storyboard(storyboard_id)
            storyboard.items = items

        return storyboard

    async def create_storyboard(
        self,
        user_id: UUID,
        storyboard_data: StoryboardCreate
    ) -> Storyboard:
        """创建分镜"""
        # 检查项目权限
        await self._check_project_permission(
            user_id, storyboard_data.project_id, 'editor'
        )

        # 获取下一个顺序索引(同时作为镜号)
        max_order = await self.repository.get_max_order(
            storyboard_data.project_id
        )
        order_index = (max_order or 0) + 1

        # 计算结束时间(如果未提供)
        end_time = storyboard_data.end_time
        if end_time is None and storyboard_data.estimated_duration:
            end_time = storyboard_data.start_time + storyboard_data.estimated_duration

        storyboard = Storyboard(
            project_id=storyboard_data.project_id,
            title=storyboard_data.title,
            description=storyboard_data.description,
            shooting_description=storyboard_data.shooting_description,
            shot_size=storyboard_data.shot_size,
            camera_movement=storyboard_data.camera_movement,
            estimated_duration=storyboard_data.estimated_duration,
            order_index=order_index,
            start_time=storyboard_data.start_time,
            end_time=end_time,
            transition_type=storyboard_data.transition_type,
            transition_duration=storyboard_data.transition_duration,
            meta_data=storyboard_data.meta_data or {}
        )

        created_storyboard = await self.repository.create(storyboard)

        return created_storyboard

    async def update_storyboard(
        self,
        user_id: UUID,
        storyboard_id: UUID,
        storyboard_data: StoryboardUpdate
    ) -> Storyboard:
        """更新分镜"""
        storyboard = await self.repository.get_by_id(storyboard_id)
        if not storyboard:
            raise NotFoundError("分镜不存在")

        # 检查权限
        await self._check_project_permission(
            user_id, storyboard.project_id, 'editor'
        )

        # 验证时间轴
        start_time = storyboard_data.start_time if storyboard_data.start_time is not None else storyboard.start_time
        end_time = storyboard_data.end_time if storyboard_data.end_time is not None else storyboard.end_time
        
        if start_time >= end_time:
            raise ValidationError("开始时间必须小于结束时间")

        # 验证时长
        if storyboard_data.estimated_duration is not None and storyboard_data.estimated_duration <= 0:
            raise ValidationError("预估时长必须大于 0")
        
        if storyboard_data.actual_duration is not None and storyboard_data.actual_duration <= 0:
            raise ValidationError("实际时长必须大于 0")

        update_data = storyboard_data.dict(exclude_unset=True)
        return await self.repository.update(storyboard_id, update_data)

    async def delete_storyboard(
        self,
        user_id: UUID,
        storyboard_id: UUID
    ) -> None:
        """删除分镜"""
        storyboard = await self.repository.get_by_id(storyboard_id)
        if not storyboard:
            raise NotFoundError("分镜不存在")

        # 检查权限
        await self._check_project_permission(
            user_id, storyboard.project_id, 'editor'
        )

        # 删除分镜
        await self.repository.delete(storyboard_id)

        # 重新排序剩余分镜(自动更新 order_index 和 shot_number)
        await self._reorder_after_delete(
            storyboard.project_id,
            storyboard.order_index
        )

    async def reorder_storyboards(
        self,
        user_id: UUID,
        project_id: UUID,
        reorder_data: StoryboardReorder
    ) -> Dict[str, Any]:
        """重新排序分镜(自动更新 order_index)"""
        # 检查权限
        await self._check_project_permission(user_id, project_id, 'editor')

        # 验证所有分镜都属于该项目
        for item in reorder_data.items:
            storyboard = await self.repository.get_by_id(item.storyboard_id)
            if not storyboard or storyboard.project_id != project_id:
                raise ValidationError(f"分镜 {item.storyboard_id} 不属于该项目")

        # 批量更新顺序
        await self.repository.batch_update_order(reorder_data.items)
        
        return {
            'success': True,
            'updated_count': len(reorder_data.items)
        }

    async def add_resource(
        self,
        user_id: UUID,
        storyboard_id: UUID,
        project_resource_id: UUID,
        resource_type: str,
        display_order: int = 0
    ) -> None:
        """添加项目素材到分镜"""
        storyboard = await self.repository.get_by_id(storyboard_id)
        if not storyboard:
            raise NotFoundError("分镜不存在")

        # 检查权限
        await self._check_project_permission(
            user_id, storyboard.project_id, 'editor'
        )

        # 验证资源类型
        if resource_type not in ['character', 'scene', 'prop', 'real']:
            raise ValidationError(f"无效的资源类型: {resource_type}")

        # 验证项目素材是否存在且归属同一项目
        from app.repositories.project_resource_repository import ProjectResourceRepository
        resource_repo = ProjectResourceRepository(self.db)
        resource = await resource_repo.get_by_id(project_resource_id)
        if not resource:
            raise NotFoundError("项目素材不存在")

        if resource.project_id != storyboard.project_id:
            raise ValidationError("素材不属于当前项目")

        await self.repository.add_resource(
            storyboard_id, project_resource_id, resource_type, display_order
        )

    async def remove_resource(
        self,
        user_id: UUID,
        storyboard_id: UUID,
        project_resource_id: UUID,
        resource_type: str
    ) -> None:
        """从分镜移除项目素材"""
        storyboard = await self.repository.get_by_id(storyboard_id)
        if not storyboard:
            raise NotFoundError("分镜不存在")

        # 检查权限
        await self._check_project_permission(
            user_id, storyboard.project_id, 'editor'
        )

        await self.repository.remove_resource(
            storyboard_id, project_resource_id, resource_type
        )

    async def get_storyboards_by_filter(
        self,
        user_id: UUID,
        project_id: UUID,
        shot_size: Optional[int] = None,
        camera_movement: Optional[int] = None,
        page: int = 1,
        page_size: int = 50
    ) -> Dict[str, Any]:
        """按景别和运镜筛选分镜列表"""
        # 检查项目权限
        await self._check_project_permission(user_id, project_id, 'viewer')

        storyboards = await self.repository.get_by_filter(
            project_id, shot_size, camera_movement, page, page_size
        )

        total = await self.repository.count_by_filter(
            project_id, shot_size, camera_movement
        )

        return {
            'items': storyboards,
            'total': total,
            'page': page,
            'page_size': page_size,
            'total_pages': (total + page_size - 1) // page_size
        }

    async def search_storyboards(
        self,
        user_id: UUID,
        project_id: UUID,
        keyword: str,
        page: int = 1,
        page_size: int = 50
    ) -> Dict[str, Any]:
        """全文搜索分镜(标题、描述、拍摄描述)"""
        # 检查项目权限
        await self._check_project_permission(user_id, project_id, 'viewer')

        storyboards = await self.repository.search_by_keyword(
            project_id, keyword, page, page_size
        )

        total = await self.repository.count_by_keyword(project_id, keyword)

        return {
            'items': storyboards,
            'total': total,
            'page': page,
            'page_size': page_size,
            'total_pages': (total + page_size - 1) // page_size
        }

    async def get_duration_statistics(
        self,
        user_id: UUID,
        project_id: UUID
    ) -> Dict[str, Any]:
        """获取项目时长统计"""
        # 检查项目权限
        await self._check_project_permission(user_id, project_id, 'viewer')

        stats = await self.repository.get_duration_stats(project_id)

        return {
            'total_estimated_duration': stats.get('total_estimated', 0),
            'total_actual_duration': stats.get('total_actual', 0),
            'storyboard_count': stats.get('count', 0),
            'avg_estimated_duration': stats.get('avg_estimated', 0),
            'avg_actual_duration': stats.get('avg_actual', 0),
            'duration_variance': stats.get('variance', 0)  # 预估与实际的差异
        }

    async def _reorder_after_delete(
        self,
        project_id: UUID,
        deleted_order: int
    ) -> None:
        """删除分镜后重新排序(自动更新 order_index)
        
        示例:
        - 删除前:order_index: 1, 2, 3, 4, 5
        - 删除 order_index=2 后:order_index: 1, 2, 3, 4(原 3,4,5 自动 -1)
        """
        storyboards = await self.repository.get_by_project(project_id)

        for storyboard in storyboards:
            if storyboard.order_index > deleted_order:
                # 更新 order_index
                await self.repository.update(
                    storyboard.id,
                    {'order_index': storyboard.order_index - 1}
                )

    async def _check_project_permission(
        self,
        user_id: UUID,
        project_id: UUID,
        required_role: str = 'viewer'
    ) -> None:
        """检查项目权限"""
        has_permission = await self.project_repository.check_user_permission(
            user_id, project_id, required_role
        )
        if not has_permission:
            raise PermissionError("没有权限访问此项目")

API 接口

1. 获取分镜列表

GET /api/v1/storyboards?project_id={project_id}

查询参数

  • project_id: 项目 ID(必填)
  • page: 页码
  • page_size: 每页数量
  • include_items: 是否包含关联元素(可选,默认 false)

响应

{
  "items": [
    {
      "storyboard_id": "019d1234-5678-7abc-def0-123456789abc",
      "project_id": "019d1234-5678-7abc-def0-987654321fed",
      "title": "开场",
      "description": "主角登场",
      "shooting_description": "大景深,前景虚化,摇臂缓慢推进,焦点从背景转移到主角面部,营造紧张氛围",
      "shot_size": 4,  // medium_shot
      "camera_movement": 4,  // dolly
      "estimated_duration": 5.5,
      "actual_duration": 6.2,
      "order_index": 1,
      "start_time": 0.000,
      "end_time": 5.000,
      "thumbnail_url": "https://example.com/thumb.jpg",
      "thumbnail_id": "019d1234-5678-7abc-def0-111111111111",
      "transition_type": "fade",
      "transition_duration": 0.5,
      "meta_data": {
        "lighting": "自然光",
        "weather": "晴天",
        "time_of_day": "下午",
        "props": ["咖啡杯", "笔记本"],
        "notes": "注意演员表情"
      },
      "created_at": "2025-01-27T10:00:00Z",
      "updated_at": "2025-01-27T10:00:00Z"
    }
  ],
  "total": 10,
  "page": 1,
  "page_size": 50,
  "total_pages": 1
}

说明

  • 默认不包含关联元素(性能优化)
  • 如需元素信息,使用 include_items=true 或调用 GET /api/v1/storyboards/{id}/items

2. 创建分镜

POST /api/v1/storyboards

请求体

{
  "project_id": "019d1234-5678-7abc-def0-987654321fed",
  "title": "开场",
  "description": "主角登场",
  "shooting_description": "大景深,前景虚化,摇臂缓慢推进,焦点从背景转移到主角面部,营造紧张氛围",
  "shot_size": 4,  // medium_shot
  "camera_movement": 4,  // dolly
  "estimated_duration": 5.5,
  "start_time": 0.000,
  "end_time": 5.000,
  "transition_type": "fade",
  "transition_duration": 0.5,
  "meta_data": {
    "lighting": "自然光",
    "weather": "晴天",
    "time_of_day": "下午",
    "props": ["咖啡杯", "笔记本"],
    "notes": "注意演员表情"
  }
}

响应

{
  "storyboard_id": "019d1234-5678-7abc-def0-123456789abc",
  "project_id": "019d1234-5678-7abc-def0-987654321fed",
  "title": "开场",
  "order_index": 1,
  "created_at": "2025-01-27T10:00:00Z"
}

说明

  • 创建分镜后,使用 POST /api/v1/storyboards/{id}/items 添加关联元素
  • 这样设计可以更灵活地控制元素的关联属性(动作、位置等)

3. 更新分镜

PUT /api/v1/storyboards/{storyboard_id}

请求体

{
  "title": "更新后的标题",
  "description": "更新后的描述",
  "shooting_description": "浅景深特写,手持摄影,轻微晃动增强真实感,光圈 f/2.8",
  "shot_size": 6,  // close_up
  "camera_movement": 1,  // static
  "estimated_duration": 6.0,
  "actual_duration": 6.5,
  "start_time": 0.000,
  "end_time": 6.000,
  "transition_type": "cut",
  "transition_duration": 0.0,
  "meta_data": {
    "lighting": "人工光",
    "notes": "特写镜头,注意焦点"
  }
}

4. 删除分镜

DELETE /api/v1/storyboards/{storyboard_id}

5. 重新排序分镜

POST /api/v1/storyboards/reorder

请求体

{
  "project_id": "019d1234-5678-7abc-def0-987654321fed",
  "items": [
    { "storyboard_id": "019d1234-5678-7abc-def0-333333333333", "order_index": 1 },
    { "storyboard_id": "019d1234-5678-7abc-def0-123456789abc", "order_index": 2 },
    { "storyboard_id": "019d1234-5678-7abc-def0-222222222222", "order_index": 3 }
  ]
}

响应

{
  "success": true,
  "updated_count": 3
}

说明:仅更新 order_index,镜号即为 order_index 的值。

6. 获取分镜的所有关联元素

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

响应

{
  "items": [
    {
      "item_id": "019d1234-5678-7abc-def0-111111111111",
      "item_type": 1,  // 1=ElementTag
      "element_tag_id": "019d1234-5678-7abc-def0-222222222222",
      "element_name": "孙悟空",
      "tag_label": "少年",
      "cover_url": "https://example.com/avatar.jpg",
      "action_description": "大笑",
      "spatial_position": "center",
      "is_visible": true,
      "display_order": 0,
      "z_index": 0,
      "element_tag": {
        "tag_id": "019d1234-5678-7abc-def0-222222222222",
        "element_type": 1,  // 1=角色
        "element_id": "019d1234-5678-7abc-def0-333333333333",
        "tag_label": "少年",
        "description": "15岁的孙悟空"
      }
    },
    {
      "item_id": "019d1234-5678-7abc-def0-444444444444",
      "item_type": 1,  // 1=ElementTag
      "element_tag_id": "019d1234-5678-7abc-def0-555555555555",
      "element_name": "咖啡厅",
      "tag_label": "白天",
      "cover_url": null,
      "action_description": null,
      "spatial_position": null,
      "is_visible": true,
      "display_order": 1,
      "z_index": 0,
      "element_tag": {
        "tag_id": "019d1234-5678-7abc-def0-555555555555",
        "element_type": 2,  // 2=场景
        "element_id": "019d1234-5678-7abc-def0-666666666666",
        "tag_label": "白天"
      }
    }
  ],
  "total": 2
}

7. 添加元素到分镜

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

请求体

{
  "item_type": 1,  // 1=ElementTag, 2=Resource
  "element_tag_id": "019d1234-5678-7abc-def0-222222222222",  // item_type=1 时必填
  "resource_id": null,  // item_type=2 时必填
  "action_description": "大笑",  // 可选
  "spatial_position": "center",  // 可选
  "is_visible": true,  // 可选,默认 true
  "z_index": 0  // 可选,默认 0
}

响应

{
  "item_id": "019d1234-5678-7abc-def0-111111111111",
  "item_type": 1,
  "element_tag_id": "019d1234-5678-7abc-def0-222222222222",
  "element_name": "孙悟空",
  "tag_label": "少年",
  "cover_url": "https://example.com/avatar.jpg",
  "action_description": "大笑",
  "spatial_position": "center",
  "is_visible": true,
  "display_order": 0,
  "z_index": 0
}

8. 更新元素的关联属性

PATCH /api/v1/storyboard-items/{item_id}

请求体

{
  "action_description": "站立",  // 可选
  "spatial_position": "left",  // 可选
  "is_visible": false,  // 可选
  "z_index": 1  // 可选
}

9. 从分镜移除元素

DELETE /api/v1/storyboard-items/{item_id}

10. 批量调整元素顺序

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

请求体

{
  "items": [
    { "item_id": "019d1234-5678-7abc-def0-111111111111", "display_order": 0 },
    { "item_id": "019d1234-5678-7abc-def0-444444444444", "display_order": 1 },
    { "item_id": "019d1234-5678-7abc-def0-777777777777", "display_order": 2 }
  ]
}

响应

{
  "success": true,
  "updated_count": 3
}

11. 按景别和运镜筛选分镜

GET /api/v1/storyboards/filter?project_id={project_id}&shot_size={shot_size}&camera_movement={camera_movement}

查询参数

  • project_id: 项目 ID(必填)
  • shot_size: 景别类型(可选,数值 1-8)
  • camera_movement: 运镜类型(可选,数值 1-9)
  • page: 页码
  • page_size: 每页数量

响应:与获取分镜列表相同

12. 全文搜索分镜

GET /api/v1/storyboards/search?project_id={project_id}&keyword={keyword}

查询参数

  • project_id: 项目 ID(必填)
  • keyword: 搜索关键词(搜索标题、描述、拍摄描述)
  • page: 页码
  • page_size: 每页数量

响应:与获取分镜列表相同

13. 获取项目时长统计

GET /api/v1/storyboards/statistics/duration?project_id={project_id}

响应

{
  "total_estimated_duration": 300.5,
  "total_actual_duration": 315.2,
  "storyboard_count": 50,
  "avg_estimated_duration": 6.01,
  "avg_actual_duration": 6.304,
  "duration_variance": 14.7
}

API 接口总结

接口 方法 路径 说明
1 GET /api/v1/storyboards 获取分镜列表
2 POST /api/v1/storyboards 创建分镜
3 PUT /api/v1/storyboards/{id} 更新分镜
4 DELETE /api/v1/storyboards/{id} 删除分镜
5 POST /api/v1/storyboards/reorder 重新排序分镜
6 GET /api/v1/storyboards/{id}/items 获取分镜的所有关联元素
7 POST /api/v1/storyboards/{id}/items 添加元素到分镜
8 PATCH /api/v1/storyboard-items/{id} 更新元素的关联属性
9 DELETE /api/v1/storyboard-items/{id} 从分镜移除元素
10 POST /api/v1/storyboards/{id}/items/reorder 批量调整元素顺序
11 GET /api/v1/storyboards/filter 按景别和运镜筛选分镜
12 GET /api/v1/storyboards/search 全文搜索分镜
13 GET /api/v1/storyboards/statistics/duration 获取项目时长统计

数据模型

Storyboard 模型

# app/models/storyboard.py
from uuid import UUID
from decimal import Decimal
from sqlalchemy import Column, String, Text, Numeric, Integer, SmallInteger, TIMESTAMP, text
from sqlalchemy.dialects.postgresql import UUID as PG_UUID, JSONB
from sqlalchemy.orm import relationship
from sqlmodel import SQLModel, Field
from app.core.database import Base, generate_uuid_v7
from datetime import datetime
from typing import Optional, Dict, Any
from enum import IntEnum

class ShotSizeType(IntEnum):
    """景别类型(基于国际影视行业标准)"""
    EXTREME_WIDE_SHOT = 1  # 大远景
    WIDE_SHOT = 2          # 远景
    FULL_SHOT = 3          # 全景
    MEDIUM_SHOT = 4        # 中景
    MEDIUM_CLOSE_UP = 5    # 中近景
    CLOSE_UP = 6           # 特写
    EXTREME_CLOSE_UP = 7   # 大特写
    OVER_SHOULDER = 8      # 过肩镜头

    @classmethod
    def from_string(cls, value: str) -> int:
        """字符串转数字"""
        mapping = {
            'extreme_wide_shot': cls.EXTREME_WIDE_SHOT,
            'wide_shot': cls.WIDE_SHOT,
            'full_shot': cls.FULL_SHOT,
            'medium_shot': cls.MEDIUM_SHOT,
            'medium_close_up': cls.MEDIUM_CLOSE_UP,
            'close_up': cls.CLOSE_UP,
            'extreme_close_up': cls.EXTREME_CLOSE_UP,
            'over_shoulder': cls.OVER_SHOULDER
        }
        return mapping.get(value.lower())

    @classmethod
    def to_string(cls, value: int) -> str:
        """数字转字符串"""
        mapping = {
            cls.EXTREME_WIDE_SHOT: 'extreme_wide_shot',
            cls.WIDE_SHOT: 'wide_shot',
            cls.FULL_SHOT: 'full_shot',
            cls.MEDIUM_SHOT: 'medium_shot',
            cls.MEDIUM_CLOSE_UP: 'medium_close_up',
            cls.CLOSE_UP: 'close_up',
            cls.EXTREME_CLOSE_UP: 'extreme_close_up',
            cls.OVER_SHOULDER: 'over_shoulder'
        }
        return mapping.get(value)

class CameraMovementType(IntEnum):
    """运镜类型(基于国际影视行业标准)"""
    STATIC = 1      # 固定
    PAN = 2         # 摇镜
    TILT = 3        # 俯仰
    DOLLY = 4       # 推拉
    ZOOM = 5        # 变焦
    TRACKING = 6    # 跟踪
    ARC = 7         # 环绕
    CRANE = 8       # 升降
    HANDHELD = 9    # 手持

    @classmethod
    def from_string(cls, value: str) -> int:
        """字符串转数字"""
        mapping = {
            'static': cls.STATIC,
            'pan': cls.PAN,
            'tilt': cls.TILT,
            'dolly': cls.DOLLY,
            'zoom': cls.ZOOM,
            'tracking': cls.TRACKING,
            'arc': cls.ARC,
            'crane': cls.CRANE,
            'handheld': cls.HANDHELD
        }
        return mapping.get(value.lower())

    @classmethod
    def to_string(cls, value: int) -> str:
        """数字转字符串"""
        mapping = {
            cls.STATIC: 'static',
            cls.PAN: 'pan',
            cls.TILT: 'tilt',
            cls.DOLLY: 'dolly',
            cls.ZOOM: 'zoom',
            cls.TRACKING: 'tracking',
            cls.ARC: 'arc',
            cls.CRANE: 'crane',
            cls.HANDHELD: 'handheld'
        }
        return mapping.get(value)

class Storyboard(SQLModel, table=True):
    __tablename__ = "storyboards"

    storyboard_id: UUID = Field(
        sa_column=Column(
            PG_UUID(as_uuid=True),
            primary_key=True,
            default=generate_uuid_v7
        )
    )
    project_id: UUID = Field(foreign_key='projects.project_id', index=True)
    
    # 基本信息
    title: str = Field(max_length=255)
    description: str = Field(sa_column=Column(Text))
    shooting_description: Optional[str] = Field(default=None, sa_column=Column(Text))
    
    # 影视专业字段
    shot_size: Optional[int] = Field(
        default=None,
        sa_column=Column(SmallInteger)
    )
    camera_movement: Optional[int] = Field(
        default=None,
        sa_column=Column(SmallInteger)
    )
    
    # 时长管理
    estimated_duration: Optional[Decimal] = Field(
        default=None,
        sa_column=Column(Numeric(10, 3))
    )
    actual_duration: Optional[Decimal] = Field(
        default=None,
        sa_column=Column(Numeric(10, 3))
    )
    
    # 时间轴定位
    start_time: Decimal = Field(
        default=Decimal('0.000'),
        sa_column=Column(Numeric(10, 3))
    )
    end_time: Decimal = Field(sa_column=Column(Numeric(10, 3)))
    
    # 缩略图
    thumbnail_url: Optional[str] = Field(default=None)
    thumbnail_id: Optional[UUID] = Field(
        default=None,
        foreign_key='attachments.attachment_id'
    )
    
    # 排序和转场
    order_index: int = Field(index=True)
    transition_type: Optional[str] = Field(default=None)
    transition_duration: Optional[Decimal] = Field(
        default=None,
        sa_column=Column(Numeric(5, 2))
    )
    
    # 扩展字段
    meta_data: Dict[str, Any] = Field(
        default_factory=dict,
        sa_column=Column(JSONB)
    )
    
    # 审计字段
    created_at: datetime = Field(
        sa_column=Column(
            TIMESTAMP(timezone=True),
            server_default=text('now()'),
            nullable=False
        )
    )
    updated_at: datetime = Field(
        sa_column=Column(
            TIMESTAMP(timezone=True),
            server_default=text('now()'),
            nullable=False
        )
    )

    # 关系
    # project = relationship("Project", back_populates="storyboards")
    # items = relationship("StoryboardItem", back_populates="storyboard")
    # videos = relationship("Video", back_populates="storyboard")

class ItemType(IntEnum):
    """元素类型枚举"""
    ELEMENT_TAG = 1  # 剧本元素标签(角色/场景/道具)
    RESOURCE = 2     # 项目素材

class StoryboardItem(SQLModel, table=True):
    """分镜元素关联表"""
    __tablename__ = "storyboard_items"

    item_id: UUID = Field(
        sa_column=Column(
            PG_UUID(as_uuid=True),
            primary_key=True,
            default=generate_uuid_v7
        )
    )
    storyboard_id: UUID = Field(
        foreign_key='storyboards.storyboard_id',
        index=True
    )
    
    # 多态关联核心
    item_type: int = Field(sa_column=Column(SmallInteger))
    
    # 关联字段
    element_tag_id: Optional[UUID] = Field(
        default=None,
        sa_column=Column(PG_UUID(as_uuid=True))
    )
    resource_id: Optional[UUID] = Field(
        default=None,
        sa_column=Column(PG_UUID(as_uuid=True))
    )
    
    # 冗余字段(优化读性能)
    element_name: Optional[str] = Field(default=None)
    tag_label: Optional[str] = Field(default=None)
    cover_url: Optional[str] = Field(default=None)
    
    # 关联属性
    is_visible: bool = Field(default=True)
    spatial_position: Optional[str] = Field(default=None)
    action_description: Optional[str] = Field(default=None, sa_column=Column(Text))
    
    # 排序与层级
    display_order: int = Field(default=0)
    z_index: int = Field(default=0)
    
    # 扩展字段
    meta_data: Dict[str, Any] = Field(
        default_factory=dict,
        sa_column=Column(JSONB)
    )
    
    created_at: datetime = Field(
        sa_column=Column(
            TIMESTAMP(timezone=True),
            server_default=text('now()'),
            nullable=False
        )
    )

    # 关系
    # storyboard = relationship("Storyboard", back_populates="items")

StoryboardCreate Schema

# app/schemas/storyboard.py
from pydantic import BaseModel, Field
from typing import Optional, List, Dict, Any
from decimal import Decimal
from uuid import UUID

class StoryboardCreate(BaseModel):
    project_id: UUID
    title: str = Field(..., min_length=1, max_length=255)
    description: str = Field(..., min_length=1)
    shooting_description: Optional[str] = None
    shot_size: Optional[int] = Field(None, ge=1, le=8)  # 1-8
    camera_movement: Optional[int] = Field(None, ge=1, le=9)  # 1-9
    estimated_duration: Optional[Decimal] = Field(None, gt=0)
    start_time: Optional[Decimal] = Field(default=Decimal('0.000'))
    end_time: Optional[Decimal] = None
    transition_type: Optional[str] = None
    transition_duration: Optional[Decimal] = None
    meta_data: Optional[Dict[str, Any]] = Field(default_factory=dict)

class StoryboardUpdate(BaseModel):
    title: Optional[str] = Field(None, min_length=1, max_length=255)
    description: Optional[str] = Field(None, min_length=1)
    shooting_description: Optional[str] = None
    shot_size: Optional[int] = Field(None, ge=1, le=8)  # 1-8
    camera_movement: Optional[int] = Field(None, ge=1, le=9)  # 1-9
    estimated_duration: Optional[Decimal] = Field(None, gt=0)
    actual_duration: Optional[Decimal] = Field(None, gt=0)
    thumbnail_url: Optional[str] = None
    thumbnail_id: Optional[UUID] = None
    start_time: Optional[Decimal] = None
    end_time: Optional[Decimal] = None
    transition_type: Optional[str] = None
    transition_duration: Optional[Decimal] = None
    meta_data: Optional[Dict[str, Any]] = None

class StoryboardReorderItem(BaseModel):
    storyboard_id: UUID
    order_index: int

class StoryboardReorder(BaseModel):
    project_id: UUID
    items: List[StoryboardReorderItem]

class StoryboardItemCreate(BaseModel):
    """添加元素到分镜的请求"""
    item_type: int = Field(..., ge=1, le=2)  # 1=ElementTag, 2=Resource
    element_tag_id: Optional[UUID] = None  # item_type=1 时必填
    resource_id: Optional[UUID] = None     # item_type=2 时必填
    action_description: Optional[str] = None
    spatial_position: Optional[str] = None
    is_visible: bool = True
    z_index: int = 0
    
    @validator('element_tag_id')
    def validate_element_tag_id(cls, v, values):
        if values.get('item_type') == 1 and not v:
            raise ValueError('element_tag_id is required when item_type=1')
        return v
    
    @validator('resource_id')
    def validate_resource_id(cls, v, values):
        if values.get('item_type') == 2 and not v:
            raise ValueError('resource_id is required when item_type=2')
        return v

class StoryboardItemUpdate(BaseModel):
    """更新元素关联属性的请求"""
    action_description: Optional[str] = None
    spatial_position: Optional[str] = None
    is_visible: Optional[bool] = None
    z_index: Optional[int] = None

class StoryboardItemReorderItem(BaseModel):
    """元素排序项"""
    item_id: UUID
    display_order: int

class StoryboardItemReorder(BaseModel):
    """批量调整元素顺序的请求"""
    items: List[StoryboardItemReorderItem]

class StoryboardResponse(BaseModel):
    storyboard_id: UUID
    project_id: UUID
    title: str
    description: str
    shooting_description: Optional[str]
    shot_size: Optional[str]
    camera_movement: Optional[str]
    estimated_duration: Optional[Decimal]
    actual_duration: Optional[Decimal]
    thumbnail_url: Optional[str]
    thumbnail_id: Optional[UUID]
    order_index: int
    start_time: Decimal
    end_time: Decimal
    transition_type: Optional[str]
    transition_duration: Optional[Decimal]
    meta_data: Dict[str, Any]
    created_at: str
    updated_at: str

    class Config:
        from_attributes = True

相关文档


文档版本:v3.4
最后更新:2026-02-03
技术栈符合度 已符合 jointo-tech-stack v1.0 规范

变更记录

  • v3.4 (2026-02-03)

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

    • storyboard_items 表重大重构:修正关联设计
      • 核心变更:剧本元素(角色/场景/道具)改为直接关联标签,而非元素+标签
      • 原因:角色有多个变体(少年/青年/中年),分镜应指定使用哪个标签
      • item_type 简化:从 4 种类型(Character/Location/Prop/Resource)简化为 2 种(ElementTag/Resource)
      • 字段变更
        • 移除 target_id(原指向元素 ID)
        • 移除 tag_id(原作为可选字段)
        • 移除 target_nametarget_cover_url
        • 新增 element_tag_id(指向 screenplay_element_tags.tag_id)
        • 新增 resource_id(指向 project_resources.project_resource_id)
        • 新增 element_name(元素名称,如"孙悟空")
        • 新增 tag_label(标签名称,如"少年")
        • 新增 cover_url(封面 URL)
      • 约束变更
        • 移除 UNIQUE (storyboard_id, target_id, tag_id)
        • 新增 CHECK 约束:确保只有一个关联字段非空
        • 新增 UNIQUE (storyboard_id, element_tag_id)
        • 新增 UNIQUE (storyboard_id, resource_id)
      • 索引变更
        • 移除 idx_storyboard_items_target
        • 移除 idx_storyboard_items_tag_id
        • 新增 idx_storyboard_items_element_tag
        • 新增 idx_storyboard_items_resource
        • 新增 idx_storyboard_items_type
    • Service 层调整
      • create_storyboards_from_ai() 参数简化,移除 element_id_maps
      • add_element_to_storyboard() 参数调整为 element_tag_idresource_id
      • get_storyboard_with_elements() 返回结构调整
    • API 接口调整
      • 添加元素接口请求体字段变更
      • 查询元素接口响应结构变更
    • 数据模型调整
      • ItemType 枚举简化为 2 个值
      • StoryboardItem 模型字段变更
      • StoryboardItemCreate Schema 新增验证器
    • 设计理念
      • 剧本元素通过标签关联,支持多变体
      • 通过标签可以反查到元素本身(tag.element_id)
      • 标签的 element_type 字段区分角色/场景/道具
      • 项目素材直接关联,无需变体概念
  • v3.2 (2026-02-02)

    • 移除 dialogue 字段
      • storyboards 表移除 dialogue 字段
      • 移除 dialogue 相关的全文搜索索引
      • 从所有 Schema 移除 dialogue 字段(StoryboardCreateStoryboardUpdateStoryboardResponse
      • 从 Service 方法移除 dialogue 处理逻辑
      • 从 Model 移除 dialogue 字段
    • 原因:对白已独立管理在 storyboard_dialogues 表中
    • 影响
      • 全文搜索范围调整为:标题、描述、拍摄描述
      • 对白查询需通过 storyboard_dialogues
      • 对白与分镜的关联通过 storyboard_id 外键
  • v3.1 (2026-02-02)

    • API 接口重构:完全移除基于 storyboard_resources 的旧接口
      • 移除接口
        • 移除 add_resource() 方法
        • 移除 remove_resource() 方法
        • 移除 _add_resources() 辅助方法
      • 移除 Schema 字段
        • StoryboardCreate 移除 character_ids[]scene_ids[]prop_ids[]
      • 移除数据模型
        • 移除 StoryboardResource 模型
        • 移除 ResourceType 枚举
      • 新增 Schema
        • StoryboardItemCreate:添加元素到分镜
        • StoryboardItemUpdate:更新元素关联属性
        • StoryboardItemReorder:批量调整元素顺序
      • 新增数据模型
        • StoryboardItem:分镜元素关联表模型
        • ItemType:元素类型枚举(IntEnum)
    • Service 层调整
      • get_storyboards() 新增 include_items 参数(默认 false,性能优化)
      • get_storyboard() 新增 include_items 参数(默认 true)
      • create_storyboard() 移除资源关联逻辑
      • reorder_storyboards() 返回操作结果
      • get_storyboards_by_filter() 参数类型从 str 改为 int
      • search_storyboards() 搜索范围扩展到描述字段
    • API 接口编号调整
      • 接口 6-10:元素管理接口
      • 接口 11:筛选分镜
      • 接口 12:搜索分镜
      • 接口 13:时长统计
    • 设计理念
      • 创建分镜与关联元素分离,提供更大灵活性
      • 统一使用 storyboard_items 表,架构清晰
      • 避免维护两套逻辑的复杂性
  • v3.0 (2026-02-01)

    • 架构重大重构:统一关联表设计
      • 废弃 UUID[] 数组字段:移除 screenplay_character_ids[], screenplay_scene_ids[] 等数组字段
      • 废弃 storyboard_resources 表:不再使用独立的资源关联表
      • 新增 storyboard_items 表:统一管理分镜与角色、场景、道具、素材的关联
    • 核心优势
      • 元数据能力:可存储关联属性(动作、位置、状态等)
      • 统一接口:前端只需调用一个 API 获取所有元素
      • 易于管理:支持排序、层级、可见性等复杂业务需求
      • 高性能:通过冗余字段避免 JOIN,读取性能极佳
    • 新增字段
      • item_type:元素类型(1=Character, 2=Location, 3=Prop, 4=Resource)
      • target_name:元素名称(冗余存储)
      • target_cover_url:元素封面(冗余存储)
      • action_description:动作描述
      • spatial_position:画面位置
      • is_visible:是否在画面内
      • tag_id:关联的标签 ID
      • display_order:显示顺序
      • z_index:视觉层级
    • 新增 API
      • GET /api/v1/storyboards/{id}/items:获取分镜的所有关联元素
      • POST /api/v1/storyboards/{id}/items:添加元素到分镜
      • PATCH /api/v1/storyboard-items/{id}:更新元素的关联属性
      • DELETE /api/v1/storyboard-items/{id}:从分镜移除元素
      • POST /api/v1/storyboards/{id}/items/reorder:批量调整元素顺序
    • 设计理念
      • 符合关系型数据库第三范式(3NF)
      • 为 AI 视频生成提供精准 Prompt 来源
      • 支撑更复杂的导演/制作需求
  • v2.5 (2026-02-01)

    • 镜号管理简化(针对 AI 视频生成场景):
      • 移除 shot_number 字段:直接使用 order_index 作为镜号
      • 极简设计:减少字段冗余,降低维护成本
      • 自动维护:创建、删除、排序时自动更新 order_index
      • 前端显示:可格式化显示(如 #1, 001),但数据库仅存储数字
      • 移除相关 API
        • 移除 PATCH /api/v1/storyboards/{id}/shot-number
        • 移除 POST /api/v1/storyboards/regenerate-shot-numbers
        • 移除 GET /api/v1/storyboards/suggest-shot-number
      • 移除 Service 方法
        • 移除 update_shot_number()
        • 移除 regenerate_shot_numbers()
        • 移除 suggest_shot_number()
        • 移除 _generate_shot_number()
      • 设计理念
        • 项目定位为 AI 视频生成,不涉及线下拍摄
        • 简单的数字编号(1, 2, 3)完全满足需求
        • 用户无需手动管理镜号
    • 移除"镜号管理设计说明"章节(不再需要)
  • v2.4 (2026-02-01)

    • 镜号管理重构
      • shot_number 改为用户可编辑字段(TEXT 类型)
      • 移除自动生成触发器,改为应用层控制
      • 创建时提供默认值(001, 002, 003),用户可修改
      • 支持任意格式:数字(001)、字母后缀(001A)、场号体系(1-1)、自定义前缀(S01-001)
      • 排序与镜号分离:调整顺序时,仅更新 order_indexshot_number 保持不变
      • 新增 API:
        • PATCH /api/v1/storyboards/{id}/shot-number:更新镜号
        • POST /api/v1/storyboards/regenerate-shot-numbers:一键重新生成镜号
        • GET /api/v1/storyboards/suggest-shot-number:建议镜号(用于加戏)
      • 新增 Service 方法:
        • update_shot_number():更新镜号
        • regenerate_shot_numbers():一键重排
        • suggest_shot_number():建议镜号
    • 设计理念
      • 满足影视制作实际需求(加戏、场号体系)
      • 避免历史引用失效(镜号不会自动变化)
      • 保留自动化便利性(提供默认值和重排功能)
  • v2.3 (2026-02-01)

    • 新增字段shooting_description(拍摄描述)
      • 类型:TEXT(可选)
      • 用途:存储景深、运镜方式、镜头语言、拍摄技巧等专业描述
      • description 区分:description 描述画面内容,shooting_description 描述拍摄技术
      • 新增全文搜索索引支持
      • 更新 Schema、Model、Service、API 示例
  • v2.2 (2026-01-22)

    • 枚举字段重构:将 PostgreSQL ENUM 类型改为 SMALLINT + 代码枚举
      • shot_size: 从 shot_size_type ENUM 改为 SMALLINT (1-8)
      • camera_movement: 从 camera_movement_type ENUM 改为 SMALLINT (1-9)
      • Python 模型从 str, enum.Enum 改为 IntEnum
      • 添加 from_string()to_string() 转换方法
      • 添加枚举值映射表
    • 原因:更好的性能、更容易扩展、与项目其他模块保持一致
  • v2.1 (2025-01-27)

    • 统一景别和运镜枚举(参考 ADR-004):
      • 景别更新为 8 种国际标准(新增 over_shoulder,去掉 medium_long_shot)
      • 运镜更新为 9 种国际标准(tracking 替代 track,新增 arc)
      • 所有枚举值使用完整单词(如 extreme_wide_shot 替代 extreme_long)
      • 基于国际影视行业标准(FilmDaft、StudioBinder 等)
    • 更新所有代码示例和 API 响应示例
  • v2.0 (2025-01-27)

    • 更新为 UUID v7 主键(PostgreSQL 17)
    • 使用 SQLModel 替代 SQLAlchemy
    • 时间精度提升至毫秒(NUMERIC(10, 3))
    • 新增影视专业字段
      • shot_number:镜号(自动生成,基于 order_index)
      • dialogue:对白/台词
      • shot_size:景别(ENUM 类型)
      • camera_movement:运镜(ENUM 类型)
      • estimated_duration:预估时长
      • actual_duration:实际时长
      • meta_data:扩展字段(JSONB)
    • 新增转场信息字段(transition_type, transition_duration)
    • 新增缩略图管理(thumbnail_id 关联 attachments 表)
    • 完善数据库设计章节(索引、约束、触发器)
    • 新增镜号自动生成触发器
    • 统一代码风格(async/await, UUID, Decimal)
    • 新增 display_order 字段到 storyboard_resources 表
    • 新增全文搜索索引(title, dialogue)
    • 完善 Service 层代码
      • 创建分镜时处理所有新增字段
      • 更新分镜时验证时长和时间轴
      • 新增按景别和运镜筛选方法
      • 新增全文搜索方法
      • 新增时长统计方法
      • 资源关联支持 display_order
      • 修复字段引用(storyboard_id)
  • v1.0 (2025-01-27):初始版本