Files
ttstd-infinite-canvas/web/src/lib/timeline/timeline-build.ts
爱笑的毛毛虫 62b9c420dd feat(canvas): 新增视频节点字幕体系与多轨时间线编辑(关联 #187) (#191)
* fix(ci): 后端测试 - 修复 responses 流式测试数据与 Grok 生图测试 SSRF 放行

* feat(canvas): 新增视频节点字幕体系与多轨时间线编辑(关联 #187)

---------

Co-authored-by: zhaoyue <zhaoyue@rongshantech.com>
2026-08-08 18:28:28 +08:00

185 lines
7.7 KiB
TypeScript

// 从画布节点构建时间线数据:视频/音频节点自动入轨,字幕条目转成字幕片段。
// 构建是单向快照:时间线保存后用户可自由拖拽,重新构建不覆盖已有时间线。
import type { CanvasNodeData } from "@/types/canvas";
import type { SrtEntry, TimelineClip, TimelineProject } from "@/types/timeline";
import { DEFAULT_AUDIO_TRACK_ID, DEFAULT_SUBTITLE_TRACK_ID, DEFAULT_VIDEO_TRACK_ID, createDefaultTracks } from "./timeline-tracks";
import { getTimelineVisualEndMs } from "./timeline-view";
function nodeDurationMs(node: CanvasNodeData, fallbackMs = 4_000): number {
const durationMs = node.metadata?.durationMs;
return durationMs && durationMs > 0 ? Math.round(durationMs) : fallbackMs;
}
/**
* 从画布节点生成时间线片段。
* - 视频节点 → 视频轨片段(顺序排布,首尾相接);
* - 音频节点 → 音频轨片段(顺序排布);
* - 视频节点的字幕条目 → 字幕轨片段(相对该视频片段起点偏移)。
*/
export function buildTimelineFromNodes(nodes: CanvasNodeData[]): TimelineProject {
const videoNodes = nodes.filter((node) => node.type === "video" && Boolean(node.metadata?.content || node.metadata?.storageKey));
const audioNodes = nodes.filter((node) => node.type === "audio" && Boolean(node.metadata?.content || node.metadata?.storageKey));
const clips: TimelineClip[] = [];
let videoCursorMs = 0;
for (const node of videoNodes) {
const durationMs = nodeDurationMs(node);
clips.push({
id: `clip-${node.id}`,
kind: "video",
nodeId: node.id,
trackId: DEFAULT_VIDEO_TRACK_ID,
startMs: videoCursorMs,
durationMs,
title: node.title || "视频片段",
sourceStartMs: 0,
sourceDurationMs: durationMs,
});
const entries = node.metadata?.subtitleEntries || [];
for (const entry of entries) {
const startMs = videoCursorMs + Math.max(0, entry.startMs);
const endMs = videoCursorMs + Math.max(entry.startMs, Math.min(entry.endMs, durationMs));
clips.push({
id: `clip-${node.id}-sub-${entry.index}`,
kind: "subtitle",
nodeId: node.id,
trackId: DEFAULT_SUBTITLE_TRACK_ID,
startMs,
durationMs: Math.max(100, endMs - startMs),
title: entry.text || "字幕",
sourceStartMs: entry.startMs,
sourceDurationMs: Math.max(0, entry.endMs - entry.startMs),
subtitleEntryIndex: entry.index,
text: entry.text,
});
}
videoCursorMs += durationMs;
}
let audioCursorMs = 0;
for (const node of audioNodes) {
const durationMs = nodeDurationMs(node);
clips.push({
id: `clip-${node.id}`,
kind: "audio",
nodeId: node.id,
trackId: DEFAULT_AUDIO_TRACK_ID,
startMs: audioCursorMs,
durationMs,
title: node.title || "音频片段",
sourceStartMs: 0,
sourceDurationMs: durationMs,
volume: 1,
fadeInMs: 0,
fadeOutMs: 0,
});
audioCursorMs += durationMs;
}
return {
version: 2,
tracks: createDefaultTracks(),
clips,
durationMs: getTimelineVisualEndMs(clips),
updatedAt: new Date().toISOString(),
};
}
/** 判断某个节点是否已经出现在时间线中(按 nodeId 去重)。 */
export function isNodeInTimeline(nodeId: string, timeline: TimelineProject | null | undefined): boolean {
return Boolean(timeline?.clips.some((clip) => clip.nodeId === nodeId));
}
/**
* 将节点字幕条目同步回项目时间线:按视频片段起点偏移重建该节点的字幕片段;
* 条目为空数组时移除该节点全部字幕片段,保证字幕弹窗与时间线互通。
*/
export function syncNodeSubtitlesToTimeline(timeline: TimelineProject, nodeId: string, entries: SrtEntry[]): TimelineProject {
const videoClip = timeline.clips.find((clip) => clip.nodeId === nodeId && clip.kind === "video");
const hasSubtitleClips = timeline.clips.some((clip) => clip.nodeId === nodeId && clip.kind === "subtitle");
if (!videoClip && !hasSubtitleClips) return timeline;
const baseMs = videoClip?.startMs ?? 0;
const sourceDurationMs = videoClip?.sourceDurationMs || videoClip?.durationMs || 0;
const clips = timeline.clips.filter((clip) => !(clip.nodeId === nodeId && clip.kind === "subtitle"));
for (const entry of entries) {
const text = (entry.text || "").trim();
if (!text) continue;
const startMs = baseMs + Math.max(0, entry.startMs);
const endMs = baseMs + Math.max(entry.startMs, Math.min(entry.endMs, sourceDurationMs || entry.endMs));
clips.push({
id: `clip-${nodeId}-sub-${entry.index}`,
kind: "subtitle",
nodeId,
trackId: DEFAULT_SUBTITLE_TRACK_ID,
startMs,
durationMs: Math.max(100, endMs - startMs),
title: text,
sourceStartMs: entry.startMs,
sourceDurationMs: Math.max(0, entry.endMs - entry.startMs),
subtitleEntryIndex: entry.index,
text,
});
}
return {
...timeline,
clips,
durationMs: getTimelineVisualEndMs(clips),
updatedAt: new Date().toISOString(),
};
}
/**
* 打开时间线弹窗时的兜底同步:以时间线内已有视频片段为锚点,按节点
* subtitleEntries 重建该节点的字幕片段(删除旧片段、补齐新增条目)。
* 仅处理已入轨的节点,避免把未入轨节点的字幕凭空加进时间线。
*/
export function syncTimelineSubtitleClips(timeline: TimelineProject, nodes: CanvasNodeData[]): TimelineProject {
let changed = false;
const clips = [...timeline.clips];
for (const videoClip of timeline.clips) {
if (videoClip.kind !== "video") continue;
const node = nodes.find((item) => item.id === videoClip.nodeId);
if (!node) continue;
const stale = clips.filter((clip) => clip.kind === "subtitle" && clip.nodeId === videoClip.nodeId);
if (stale.length) {
for (const staleClip of stale) {
const index = clips.indexOf(staleClip);
if (index >= 0) clips.splice(index, 1);
}
changed = true;
}
const entries = node.metadata?.subtitleEntries || [];
const baseMs = videoClip.startMs;
const sourceDurationMs = videoClip.sourceDurationMs || videoClip.durationMs || 0;
for (const entry of entries) {
const text = (entry.text || "").trim();
if (!text) continue;
const startMs = baseMs + Math.max(0, entry.startMs);
const endMs = baseMs + Math.max(entry.startMs, Math.min(entry.endMs, sourceDurationMs || entry.endMs));
clips.push({
id: `clip-${videoClip.nodeId}-sub-${entry.index}`,
kind: "subtitle",
nodeId: videoClip.nodeId,
trackId: DEFAULT_SUBTITLE_TRACK_ID,
startMs,
durationMs: Math.max(100, endMs - startMs),
title: text,
sourceStartMs: entry.startMs,
sourceDurationMs: Math.max(0, entry.endMs - entry.startMs),
subtitleEntryIndex: entry.index,
text,
});
changed = true;
}
}
if (!changed) return timeline;
return {
...timeline,
clips,
durationMs: getTimelineVisualEndMs(clips),
updatedAt: new Date().toISOString(),
};
}