智剧通(ZJT)文档中心 时间轴柱子系统
本文档抓取自官方文档中心(2026-10-10 快照),具体功能以实际产品为准。

时间轴柱子系统文档

概述

时间轴柱子系统是视频工作流时间轴的核心架构,用于实现基于分镜的时间区域管理。每个柱子对应一个分镜(shot),作为视频轴和音频轴的占位符和分区。

核心概念

1. 柱子(Pillar)

柱子是时间轴的基本分区单位,每个柱子对应剧本中的一个分镜。

柱子标识格式: {scriptId}_{shotNumber}

例如:

2. 柱子数据结构

{
  id: "123_1",              // 柱子标识: {scriptId}_{shotNumber}
  scriptId: 123,            // 剧本节点ID
  shotNumber: 1,            // 分镜编号
  defaultDuration: 15,      // 默认时长(秒),等于分镜组最长时长
  videoClipIds: [1, 2],     // 该柱子内的视频片段ID列表
  audioClipIds: [3, 4]      // 该柱子内的音频片段ID列表
}

3. 柱子的默认时长

柱子的默认时长等于其所属分镜组的最长时长(来自剧本解析)。这个时长来自 script_parser.py 返回的 max_group_duration 参数。

4. 柱子的实际时长

柱子的实际时长根据是否有媒体内容来决定:

有媒体时(视频或音频至少有一个):

实际时长 = max(视频轨道总时长, 音频轨道总时长)

无媒体时(既没有视频也没有音频):

实际时长 = 默认时长

实现逻辑:

function getPillarActualDuration(pillar) {
  // 计算视频轨道总时长
  let videoTrackDuration = 0;
  pillar.videoClipIds.forEach(clipId => {
    const clip = state.timeline.clips.find(c => c.id === clipId);
    if (clip) {
      const actualDuration = (clip.endTime || clip.duration) - (clip.startTime || 0);
      videoTrackDuration += actualDuration;
    }
  });

  // 计算音频轨道总时长
  let audioTrackDuration = 0;
  pillar.audioClipIds.forEach(clipId => {
    const clip = state.timeline.audioClips.find(c => c.id === clipId);
    if (clip) {
      const actualDuration = (clip.endTime || clip.duration) - (clip.startTime || 0);
      audioTrackDuration += actualDuration;
    }
  });

  // 优先使用实际媒体时长,只有在完全没有媒体时才使用默认时长
  if (videoTrackDuration > 0 || audioTrackDuration > 0) {
    return Math.max(videoTrackDuration, audioTrackDuration);
  }
  return pillar.defaultDuration;
}

设计理由:

工作原理

1. 柱子的预创建

关键机制:柱子在剧本解析时预先创建,而不是在片段加入时才创建。

当用户点击"拆分剧本"按钮并成功解析剧本后,系统会:

  1. 遍历剧本中的所有 shot_groups 和 shots
  2. 为每个分镜预先创建对应的柱子
  3. 柱子按 shot_number 顺序排列
  4. 即使某些镜头的视频还未生成,柱子也已经存在
  5. 自动显示时间轴,展示所有空柱子

这样设计的好处:

示例:

// 剧本解析时预创建柱子
result.data.shot_groups.forEach((shotGroup) => {
  shotGroup.shots.forEach((shot) => {
    createOrUpdatePillar(scriptId, shot.shot_number, shot.duration);
  });
});

// 后续添加片段时,直接关联到已存在的柱子
const pillar = getPillarForNode(nodeId);
addClipToPillar(pillar, clipId, 'video');

2. 柱子的匹配逻辑

分镜节点:

对话组节点:

3. 时间轴渲染

时间轴按柱子顺序渲染,每个柱子:

  1. 按 shotNumber 排序:确保分镜按顺序显示
  2. 计算累计位置:每个柱子的起始位置 = 前面所有柱子的实际时长之和
  3. 渲染柱子背景:使用交替颜色区分不同柱子
  4. 渲染片段:在柱子内按 order 排序渲染视频和音频片段

渲染示例:

时间轴: [柱子1: 15秒] [柱子2: 20秒] [柱子3: 15秒]
位置:   0-15秒        15-35秒        35-50秒

3. UI 轨道高度

4. 时间轴刻度尺

刻度尺结构:

刻度类型:

样式特性:

DOM结构:

<div class="timeline-ruler">
  <div style="display: flex;">
    <div style="width: 60px;"><!-- 左侧占位 --></div>
    <div style="position: relative; flex: 1;">
      <!-- 主刻度 -->
      <div class="ruler-mark" style="left: 0px;">
        <div class="ruler-tick ruler-tick-major"></div>
        <div class="ruler-label">0:00</div>
      </div>
      <!-- 次刻度 -->
      <div class="ruler-mark-minor" style="left: 10px;">
        <div class="ruler-tick ruler-tick-minor"></div>
      </div>
      ...
    </div>
  </div>
</div>

移动约束

核心规则:视频和音频片段可以在不同柱子之间移动(包括自由柱子)。

实现方式:

// 跨柱子移动
if (draggedClip.pillarId !== targetClip.pillarId) {
  moveClipToPillar(draggedClipId, targetClip.pillarId, 'video', insertOrder);
} else {
  // 同柱子内排序
  moveTimelineClipToPosition(draggedClipId, finalPosition);
}

5. 动态时长调整

当柱子内添加或删除片段时:

  1. 重新计算柱子实际时长:取默认时长、视频轨道总时长、音频轨道总时长的最大值
  2. 后续柱子自动后移:后续所有柱子的起始位置自动调整
  3. 保持连续性:确保时间轴没有空隙

示例:

初始状态:
柱子1(默认15秒) | 柱子2(默认15秒)
0-15秒          | 15-30秒

添加30秒视频到柱子1后:
柱子1(实际30秒) | 柱子2(默认15秒)
0-30秒          | 30-45秒  <- 自动后移15秒

API 函数

柱子管理

createOrUpdatePillar(scriptId, shotNumber, defaultDuration)

创建或更新柱子。

参数:

返回:柱子对象

getPillarActualDuration(pillar)

获取柱子的实际时长。

参数:

返回:实际时长(秒)

getPillarForNode(nodeId)

根据节点ID获取对应的柱子。

参数:

返回:柱子对象或null

片段管理

addClipToPillar(pillar, clipId, trackType)

将片段添加到柱子。

参数:

removeClipFromPillar(clipId, trackType)

从柱子移除片段。

参数:

getPillarForClip(clipId, trackType)

获取片段所属的柱子。

参数:

返回:柱子对象或null

约束检查

canMoveClipTo(clipId, targetClipId, trackType)

检查片段是否可以移动到目标位置。

参数:

返回:布尔值,true表示允许移动

使用场景

场景1:解析剧本并预创建柱子

// 1. 用户点击"拆分剧本"按钮
// 2. 系统调用后端API解析剧本
// 3. 解析成功后,遍历所有分镜并预创建柱子
result.data.shot_groups.forEach((shotGroup) => {
  shotGroup.shots.forEach((shot) => {
    createOrUpdatePillar(scriptId, shot.shot_number, shot.duration);
  });
});

// 4. 自动显示时间轴(即使还没有片段)
state.timeline.visible = true;
renderTimeline();

// 结果:时间轴显示所有空柱子,等待用户添加视频/音频

场景2:添加视频到时间轴

// 1. 用户点击"加时间轴"按钮
// 2. 系统调用 addToTimeline(nodeId)
// 3. 系统查找节点对应的柱子(柱子已预先创建)
let pillar = getPillarForNode(nodeId);

// 4. 如果找不到柱子,使用自由柱子(独立片段)
if (!pillar) {
  pillar = getOrCreateFreePillar();
}

// 5. 创建视频片段并关联到柱子
const clip = {
  id: state.timeline.nextClipId++,
  nodeId: nodeId,
  url: node.data.url,
  pillarId: pillar.id,
  order: pillar.videoClipIds.length, // 在柱子内的顺序
  // ... 其他属性
};

// 6. 将片段添加到柱子
addClipToPillar(pillar, clip.id, 'video');

// 7. 渲染时间轴(片段会出现在对应柱子的位置)
renderTimeline();

场景3:拖拽片段跨柱子移动

// 1. 用户拖拽片段A到片段B的位置(可能是不同柱子)
// 2. 系统检查柱子约束
if (!canMoveClipTo(clipA.id, clipB.id, 'video')) {
  showToast('无法移动到该位置', 'warning');
  return;
}

// 3. 根据是否跨柱子选择不同移动方式
if (draggedClip.pillarId !== targetClip.pillarId) {
  // 跨柱子移动:更新 pillarId
  moveClipToPillar(draggedClipId, targetClip.pillarId, 'video', insertOrder);
} else {
  // 同柱子内排序:只更新 order
  moveTimelineClipToPosition(draggedClipId, finalPosition);
}

// 4. 重新渲染
renderTimeline();

场景4:拖拽片段到空柱子背景

// 1. 用户将片段拖拽到某个空柱子的背景区域
// 2. 柱子背景 dragover 事件触发,显示高亮
// 3. 柱子背景 drop 事件触发
const clipId = Number(e.dataTransfer.getData('text/plain'));
const targetPillarId = pillarEl.dataset.pillarId;

// 4. 如果片段不在目标柱子,执行跨柱子移动
if (clip.pillarId !== targetPillarId) {
  moveClipToPillar(clipId, targetPillarId, 'video');
}

场景4:删除片段

// 1. 用户点击删除按钮
// 2. 从柱子中移除片段
removeClipFromPillar(clipId, 'video');

// 3. 从clips数组中移除
state.timeline.clips = state.timeline.clips.filter(c => c.id !== clipId);

// 4. 重新计算柱子时长并渲染
renderTimeline();

向后兼容

系统保持向后兼容,支持两种模式:

柱子模式

经典模式

数据持久化

柱子数据会随工作流一起保存和恢复:

// 序列化
timeline: {
  clips: [...],
  audioClips: [...],
  pillars: [...]  // 柱子数据
}

// 恢复
state.timeline.pillars = data.timeline.pillars || [];

历史数据自动迁移

为了兼容旧版工作流,系统实现了自动迁移机制。

迁移时机

自动迁移会在以下情况触发:

  1. 添加片段时:当用户点击"加时间轴"按钮,但系统检测到没有柱子数据时
  2. 恢复工作流时:加载旧版工作流,检测到有片段但没有柱子数据时

迁移逻辑

function autoMigratePillars() {
  // 1. 检查是否已有柱子数据
  if (state.timeline.pillars.length > 0) return false;

  // 2. 扫描画布上所有已解析的剧本节点
  const scriptNodes = state.nodes.filter(n => 
    n.type === 'script' && n.data.parsedData
  );

  // 3. 基于剧本的 parsedData 重建柱子
  scriptNodes.forEach(scriptNode => {
    const parsedData = scriptNode.data.parsedData;
    parsedData.shot_groups.forEach(shotGroup => {
      shotGroup.shots.forEach(shot => {
        createOrUpdatePillar(scriptId, shot.shot_number, shot.duration);
      });
    });
  });

  // 4. 自动显示时间轴
  state.timeline.visible = true;
  return true;
}

迁移效果

迁移条件

迁移需要满足以下条件:

  1. 画布上存在已解析的剧本节点(node.type === 'script')
  2. 剧本节点包含 parsedData(已执行过"拆分剧本"操作)
  3. parsedData 中包含 shot_groups 和 shots 数据

无法迁移的情况

如果出现以下情况,无法自动迁移:

对于视频节点不是通过剧本分镜生成的(独立上传的视频),系统会将其放入自由柱子(__free__),不再拒绝添加。

时间轴点击跳转

1. 点击视频片段跳转到视频节点

点击时间轴中的视频片段时,画布会自动平移并居中到该片段对应的视频节点,同时选中并高亮该节点。

实现方式:

2. 点击空柱子跳转到分镜节点

当某个镜头还没有视频片段时,点击该柱子的空白区域,画布会跳转到对应的分镜节点。

实现方式:

相关函数:

注意事项

  1. 柱子标识唯一性:同一个剧本的同一个分镜只有一个柱子
  2. 时长动态性:柱子时长会随内容变化而自动调整
  3. 跨柱子移动:片段可以在不同柱子之间移动(包括从自由柱子到分镜柱子)
  4. 空柱子:即使柱子内没有片段,也会占据默认时长的空间
  5. 前置空元素:新片段会自动放入对应柱子,前面用空元素占位
  6. 历史兼容:旧版工作流会自动迁移到新的柱子系统
  7. 自由柱子清理:当自由柱子内所有片段都被移出/删除后,空自由柱子会自动清理

调试信息

系统会在控制台输出详细的柱子操作日志:

[柱子系统] 创建柱子: 123_1, 默认时长: 15秒
[时间轴] 视频片段 1 已关联到柱子 123_1
[恢复工作流] 恢复了 5 个柱子

自由柱子(独立片段)

概念

自由柱子(ID为 __free__)用于容纳未关联任何分镜节点的独立视频/音频片段。当用户将独立的视频节点或音频节点加入时间轴时,如果该节点没有通过连接链关联到任何分镜节点,片段会被放入自由柱子。

数据结构

{
  id: "__free__",           // 固定标识
  scriptId: null,           // 无剧本关联
  shotNumber: null,          // 无分镜关联
  defaultDuration: 15,       // 默认时长
  videoClipIds: [],          // 独立视频片段ID列表
  audioClipIds: []           // 独立音频片段ID列表
}

视觉区分

自动清理

当自由柱子内的所有片段都被移出(移到其他柱子)或删除后,空自由柱子会自动移除,避免在时间轴开头渲染空区域。

API 函数

getOrCreateFreePillar()

获取或创建自由柱子。当独立片段需要加入时间轴时调用。

cleanupFreePillarIfEmpty()

清理空的自由柱子。在片段移除或跨柱子移动后调用。

moveClipToPillar(clipId, targetPillarId, trackType, insertOrder)

跨柱子移动片段:从源柱子移除,加入目标柱子,更新片段的 pillarId 和 order。

bindPillarDropEvents()

绑定柱子背景区域的拖拽事件,允许将片段拖拽到空柱子的背景区域。

未来扩展

可能的扩展方向:

  1. 柱子合并:允许合并相邻的柱子
  2. 柱子分割:将一个柱子分割为多个
  3. 柱子锁定:锁定柱子时长,不允许自动扩展
  4. 柱子颜色自定义:允许用户自定义柱子背景颜色
  5. 柱子标签:在柱子上显示更多信息(场景、角色等)