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

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}
  • 扩展选中状态数据结构

向后兼容

  • 现有单元素块轨道(分镜、资源)不受影响
  • 新字段都是可选的
  • 没有对白/视频/音效时显示空轨道

后续优化

  1. 拖拽调整顺序:支持拖拽改变元素块顺序
  2. 独立时间调整:每个元素块可以独立调整时长
  3. 批量操作:选中多个元素块进行批量操作
  4. 视觉优化:不同类型元素块使用不同样式

参考