# 分镜管理服务 > **文档版本**:v2.0 > **最后更新**:2025-01-27 --- ## 目录 1. [服务概述](#服务概述) 2. [核心功能](#核心功能) 3. [数据库设计](#数据库设计) 4. [服务实现](#服务实现) 5. [API 接口](#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 表结构 ```sql -- 创建分镜表(应用层生成 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 表结构(统一关联表) ```sql -- 创建分镜元素关联表(应用层生成 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 即可 3. **镜号管理**: - 使用 `order_index` 作为镜号 - 创建时自动分配下一个序号(1, 2, 3...) - 删除时,后续分镜的 `order_index` 自动 -1 - 排序时,批量更新 `order_index` - 前端显示时可格式化(如 `#1`, `001`) - **极简设计**:无需单独维护镜号字段 4. **影视专业字段**: - **景别(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`:实际时长(制作完成后填写) - 两者可独立存在,便于对比分析 5. **时间轴定位**: - `start_time/end_time`:用于视频编辑时间轴定位 - 与 `estimated_duration/actual_duration` 区分 - 支持时间范围查询(GiST 索引) 6. **扩展字段(meta_data)**: - 灯光(lighting) - 天气(weather) - 时段(time_of_day) - 道具列表(props) - 备注(notes) - 参考图片(reference_images) - 使用 GIN 索引支持 JSONB 查询 7. **缩略图管理**: - `thumbnail_url`:直接存储 URL(兼容旧数据) - `thumbnail_id`:关联 attachments 表(推荐方式,一对一,应用层验证) 8. **转场信息**: - `transition_type`:转场类型(淡入淡出、切换等) - `transition_duration`:转场时长(秒) 9. **顺序管理**: - 使用 `DEFERRABLE INITIALLY DEFERRED` 处理顺序调整时的唯一约束冲突 - 支持批量重排序 - 镜号自动同步更新 10. **索引策略**: - B-tree 索引:project_id, order_index - 部分索引:shot_size, camera_movement(仅非空值) - GiST 索引:时间范围查询 - GIN 索引:meta_data, 全文搜索(title, dialogue) 11. **元素关联(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 约束**:确保只有一个关联字段非空 - **唯一约束**:防止重复关联同一标签或素材 12. **数据完整性**: - CHECK 约束:时间范围、时长正值 - UNIQUE 约束:顺序索引、元素关联去重 - 触发器:自动更新 updated_at 13. **审计追踪**: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,读取性能极佳 ### 关联表设计 ```sql -- 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 创建分镜并自动建立关联: ```python 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 返回的分镜数据结构: ```json { "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 来源 ### 查询分镜元素 查询分镜时,自动加载关联的所有元素: ```python 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 查询分镜关联的元素信息: ```sql -- 查询分镜关联的所有元素(统一查询) 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 区分角色/场景/道具 ### 手动管理元素 用户也可以手动添加或移除分镜的关联元素: ```python 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. **去重检查**:添加元素前检查是否已存在 ### 相关文档 - [AI 解析剧本工作流](../../workflows/screenplay-ai-parse-workflow.md) - [Screenplay Service 文档](./screenplay-service.md) - [Screenplay Tag Service 文档](./screenplay-tag-service.md) --- ## 服务实现 ### StoryboardService 类 ```python # 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) **响应**: ```json { "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 ``` **请求体**: ```json { "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": "注意演员表情" } } ``` **响应**: ```json { "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} ``` **请求体**: ```json { "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 ``` **请求体**: ```json { "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 } ] } ``` **响应**: ```json { "success": true, "updated_count": 3 } ``` **说明**:仅更新 `order_index`,镜号即为 `order_index` 的值。 ### 6. 获取分镜的所有关联元素 ``` GET /api/v1/storyboards/{storyboard_id}/items ``` **响应**: ```json { "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 ``` **请求体**: ```json { "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 } ``` **响应**: ```json { "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} ``` **请求体**: ```json { "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 ``` **请求体**: ```json { "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 } ] } ``` **响应**: ```json { "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} ``` **响应**: ```json { "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 模型 ```python # 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 ```python # 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 ``` --- ## 相关文档 - [项目管理服务](./project-service.md) - [资源管理服务](./resource-service.md) - [系统架构设计](../03-system-design.md) --- **文档版本**: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_name`、`target_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_id` 和 `resource_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` 字段(`StoryboardCreate`、`StoryboardUpdate`、`StoryboardResponse`) - ❌ 从 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_index`,`shot_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)**:初始版本