79 KiB
分镜管理服务
文档版本:v2.0
最后更新:2025-01-27
目录
服务概述
分镜管理服务负责处理分镜的创建、查询、更新、排序等业务逻辑,是视频创作的核心模块。
职责
- 分镜 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);
设计说明
-
主键类型:使用 UUID v7(应用层生成,通过
generate_uuid函数) -
引用完整性保证:
- ⚠️ 禁止物理外键约束:数据库层不创建 FOREIGN KEY
- ✅ 应用层验证:Service 层负责验证所有引用关系
- ✅ 必须创建索引:所有关联字段创建索引以保证查询性能
-
关联设计理念:
- 剧本元素(角色/场景/道具):关联到
screenplay_element_tags(标签表)- 原因:角色有多个变体(少年/青年/中年),场景有多个状态(白天/夜晚),道具有多个形态(新/旧)
- 分镜需要指定使用哪个标签(变体)
- 通过标签可以反查到元素本身(tag.element_id)
- 项目素材:直接关联到
project_resources- 原因:实拍素材、音频、视频等不需要变体概念
- 直接使用素材 ID 即可
- 剧本元素(角色/场景/道具):关联到
-
镜号管理:
- 使用
order_index作为镜号 - 创建时自动分配下一个序号(1, 2, 3...)
- 删除时,后续分镜的
order_index自动 -1 - 排序时,批量更新
order_index - 前端显示时可格式化(如
#1,001) - 极简设计:无需单独维护镜号字段
- 使用
-
影视专业字段:
- 景别(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:实际时长(制作完成后填写)- 两者可独立存在,便于对比分析
- 景别(shot_size):使用 SMALLINT 存储(1-8),代码层使用 IntEnum(8种标准景别,基于国际影视行业标准)
-
时间轴定位:
start_time/end_time:用于视频编辑时间轴定位- 与
estimated_duration/actual_duration区分 - 支持时间范围查询(GiST 索引)
-
扩展字段(meta_data):
- 灯光(lighting)
- 天气(weather)
- 时段(time_of_day)
- 道具列表(props)
- 备注(notes)
- 参考图片(reference_images)
- 使用 GIN 索引支持 JSONB 查询
-
缩略图管理:
thumbnail_url:直接存储 URL(兼容旧数据)thumbnail_id:关联 attachments 表(推荐方式,一对一,应用层验证)
-
转场信息:
transition_type:转场类型(淡入淡出、切换等)transition_duration:转场时长(秒)
-
顺序管理:
- 使用
DEFERRABLE INITIALLY DEFERRED处理顺序调整时的唯一约束冲突 - 支持批量重排序
- 镜号自动同步更新
- 使用
-
索引策略:
- B-tree 索引:project_id, order_index
- 部分索引:shot_size, camera_movement(仅非空值)
- GiST 索引:时间范围查询
- GIN 索引:meta_data, 全文搜索(title, dialogue)
- 元素关联(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 约束:确保只有一个关联字段非空
- 唯一约束:防止重复关联同一标签或素材
-
数据完整性:
- CHECK 约束:时间范围、时长正值
- UNIQUE 约束:顺序索引、元素关联去重
- 触发器:自动更新 updated_at
-
审计追踪: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表(符合关系型数据库第三范式)
核心优势:
- 元数据能力:可存储关联属性(动作、位置、状态等),为 AI 视频生成提供精准 Prompt
- 统一接口:前端只需调用一个 API 获取所有元素
- 易于管理:支持排序、层级、可见性等复杂业务需求
- 高性能:通过冗余字段避免 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
}
数据完整性保证
- 应用层验证:Service 层负责验证所有引用关系
- 冗余字段同步:当元素名称或封面变更时,异步更新
storyboard_items表 - 级联删除:删除分镜时自动删除所有关联记录
- 去重检查:添加元素前检查是否已存在
相关文档
服务实现
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_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_mapsadd_element_to_storyboard()参数调整为element_tag_id和resource_idget_storyboard_with_elements()返回结构调整
- API 接口调整:
- 添加元素接口请求体字段变更
- 查询元素接口响应结构变更
- 数据模型调整:
ItemType枚举简化为 2 个值StoryboardItem模型字段变更StoryboardItemCreateSchema 新增验证器
- 设计理念:
- 剧本元素通过标签关联,支持多变体
- 通过标签可以反查到元素本身(tag.element_id)
- 标签的 element_type 字段区分角色/场景/道具
- 项目素材直接关联,无需变体概念
- storyboard_items 表重大重构:修正关联设计
-
v3.2 (2026-02-02):
- 移除 dialogue 字段:
- ❌ 从
storyboards表移除dialogue字段 - ❌ 移除
dialogue相关的全文搜索索引 - ❌ 从所有 Schema 移除
dialogue字段(StoryboardCreate、StoryboardUpdate、StoryboardResponse) - ❌ 从 Service 方法移除
dialogue处理逻辑 - ❌ 从 Model 移除
dialogue字段
- ❌ 从
- 原因:对白已独立管理在
storyboard_dialogues表中 - 影响:
- 全文搜索范围调整为:标题、描述、拍摄描述
- 对白查询需通过
storyboard_dialogues表 - 对白与分镜的关联通过
storyboard_id外键
- 移除 dialogue 字段:
-
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改为intsearch_storyboards()搜索范围扩展到描述字段
- API 接口编号调整:
- 接口 6-10:元素管理接口
- 接口 11:筛选分镜
- 接口 12:搜索分镜
- 接口 13:时长统计
- 设计理念:
- 创建分镜与关联元素分离,提供更大灵活性
- 统一使用
storyboard_items表,架构清晰 - 避免维护两套逻辑的复杂性
- API 接口重构:完全移除基于
-
v3.0 (2026-02-01):
- 架构重大重构:统一关联表设计
- 废弃 UUID[] 数组字段:移除
screenplay_character_ids[],screenplay_scene_ids[]等数组字段 - 废弃 storyboard_resources 表:不再使用独立的资源关联表
- 新增 storyboard_items 表:统一管理分镜与角色、场景、道具、素材的关联
- 废弃 UUID[] 数组字段:移除
- 核心优势:
- ✅ 元数据能力:可存储关联属性(动作、位置、状态等)
- ✅ 统一接口:前端只需调用一个 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:关联的标签 IDdisplay_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)完全满足需求
- 用户无需手动管理镜号
- 移除
- 移除"镜号管理设计说明"章节(不再需要)
- 镜号管理简化(针对 AI 视频生成场景):
-
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()转换方法 - 添加枚举值映射表
- 原因:更好的性能、更容易扩展、与项目其他模块保持一致
- 枚举字段重构:将 PostgreSQL ENUM 类型改为 SMALLINT + 代码枚举
-
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 响应示例
- 统一景别和运镜枚举(参考 ADR-004):
-
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):初始版本