You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
6.8 KiB
6.8 KiB
RFC 136: 时间轴多元素块系统
状态: 实施中
创建时间: 2026-01-28
优先级: 高
背景
当前时间轴架构中,每个分镜在每个轨道上只有一个元素块。新需求要求支持一个分镜在某些轨道上有多个独立的元素块,每个块显示不同的内容。
需求
1. 对白轨道(原"台词"轨道)
- 轨道名称从"台词"改为"对白"
- 一个分镜可以有多段对白(对话形式)
- 每段对白是独立的块,显示对白文字
- 多个对白块在分镜时间范围内等分宽度
- 没有对白时不显示块
2. 视频轨道
- 一个分镜可以有多个视频片段
- 每个视频是独立的块
- 多个视频块等分宽度
3. 音效轨道
- 显示音效名称
- 一个分镜可以有多个音效
- 多个音效块等分宽度
4. 配音轨道
- 一个分镜可以有多个配音
- 多个配音块等分宽度
5. 宽度计算规则
- 分镜宽度 = 240px(示例)
- 2个元素块 = 每个 120px(等分)
- 需要添加间隔(2px gap)
技术方案
数据结构
1. 分镜对白
export interface StoryboardDialogue {
id: string;
text: string;
characterName?: string;
displayOrder: number;
}
export interface Storyboard {
// ... 其他字段
dialogues?: StoryboardDialogue[];
}
2. 关联资源
export interface Video {
// ... 其他字段
storyboardId?: string;
displayOrder?: number;
}
export interface SoundEffect {
// ... 其他字段
storyboardId?: string;
displayOrder?: number;
}
export interface Voiceover {
// ... 其他字段
storyboardId?: string;
displayOrder?: number;
}
渲染逻辑
TimelineTrack 组件变更
当前逻辑:
// 一个分镜 = 一个 TimelineItem
track.items.map((item) => (
<TimelineItem
startTime={item.startTime}
endTime={item.endTime}
// ...
/>
))
新逻辑:
// 一个分镜可能 = 多个 TimelineItem
track.items.map((item) => {
const elements = getElementsForTrack(item, track.type);
if (elements.length === 0) return null;
return elements.map((element, index) => {
const { startTime, endTime } = calculateElementPosition(
item.startTime,
item.endTime,
index,
elements.length
);
return (
<TimelineItem
key={element.id}
startTime={startTime}
endTime={endTime}
content={element.content}
// ...
/>
);
});
})
元素获取函数
function getElementsForTrack(
item: TimelineItem,
trackType: TrackType,
storyboard?: StoryboardWithUI,
videos?: Video[],
soundEffects?: SoundEffect[],
voiceovers?: Voiceover[]
): ElementBlock[] {
switch (trackType) {
case 'subtitle': // 对白轨道
return storyboard?.dialogues?.map(d => ({
id: d.id,
content: d.text,
type: 'dialogue'
})) || [];
case 'video':
return videos
?.filter(v => v.storyboardId === item.storyboardId)
.sort((a, b) => (a.displayOrder || 0) - (b.displayOrder || 0))
.map(v => ({
id: v.id,
content: v.name,
type: 'video'
})) || [];
case 'sound':
return soundEffects
?.filter(s => s.storyboardId === item.storyboardId)
.sort((a, b) => (a.displayOrder || 0) - (b.displayOrder || 0))
.map(s => ({
id: s.id,
content: s.name,
type: 'sound'
})) || [];
case 'voice':
return voiceovers
?.filter(v => v.storyboardId === item.storyboardId)
.sort((a, b) => (a.displayOrder || 0) - (b.displayOrder || 0))
.map(v => ({
id: v.id,
content: v.text,
type: 'voiceover'
})) || [];
default:
return [{
id: item.id,
content: item.title,
type: 'default'
}];
}
}
位置计算函数
function calculateElementPosition(
storyboardStart: number,
storyboardEnd: number,
elementIndex: number,
totalElements: number
): { startTime: number; endTime: number } {
const duration = storyboardEnd - storyboardStart;
const elementDuration = duration / totalElements;
return {
startTime: storyboardStart + (elementDuration * elementIndex),
endTime: storyboardStart + (elementDuration * (elementIndex + 1))
};
}
TimelineItem 组件变更
添加 content prop 用于显示元素内容:
interface TimelineItemProps {
// ... 现有 props
content?: string; // 元素内容(对白文字、音效名称等)
contentType?: 'dialogue' | 'video' | 'sound' | 'voiceover';
}
// 渲染内容
{contentType === 'dialogue' && (
<span className="text-xs truncate">{content}</span>
)}
{contentType === 'sound' && (
<span className="text-xs truncate">{content}</span>
)}
实施步骤
阶段 1:数据层 ✅
- 扩展类型定义
- 创建 Mock 数据
- 更新导出
阶段 2:工具函数(进行中)
- 创建
utils/timeline-elements.ts - 实现
getElementsForTrack() - 实现
calculateElementPosition()
阶段 3:组件更新
- 更新
TimelineTrack.tsx支持多元素渲染 - 更新
TimelineItem.tsx显示元素内容 - 处理间隔和宽度计算
阶段 4:轨道名称
- 将"台词"改为"对白"
- 更新 i18n 翻译
阶段 5:测试
- 单个元素块测试
- 多个元素块测试
- 等分宽度验证
- 间隔显示验证
技术挑战
1. 宽度计算精度
- 等分可能产生小数像素
- 需要处理舍入误差
- 间隔会影响实际可用宽度
解决方案:
const GAP = 2; // 间隔 2px
const totalGaps = (totalElements - 1) * GAP;
const availableWidth = totalWidth - totalGaps;
const elementWidth = availableWidth / totalElements;
2. 性能优化
- 多个元素块增加渲染负担
- 需要优化 memo 和 key
解决方案:
- 使用稳定的 key(element.id)
- memo 包裹 TimelineItem
- 避免不必要的重渲染
3. 交互处理
- 点击、拖拽需要识别具体元素
- 选中状态管理更复杂
解决方案:
- 使用复合 ID:
${storyboardId}-${elementId} - 扩展选中状态数据结构
向后兼容
- 现有单元素块轨道(分镜、资源)不受影响
- 新字段都是可选的
- 没有对白/视频/音效时显示空轨道
后续优化
- 拖拽调整顺序:支持拖拽改变元素块顺序
- 独立时间调整:每个元素块可以独立调整时长
- 批量操作:选中多个元素块进行批量操作
- 视觉优化:不同类型元素块使用不同样式