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

* fix(ci): 后端测试 - 修复 responses 流式测试数据与 Grok 生图测试 SSRF 放行

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

---------

Co-authored-by: zhaoyue <zhaoyue@rongshantech.com>
This commit is contained in:
爱笑的毛毛虫
2026-08-08 18:28:28 +08:00
committed by GitHub
parent c75953f3c9
commit 43e65570a5
33 changed files with 3724 additions and 30 deletions

View File

@@ -43,6 +43,21 @@ export const canvasThemes = {
glowStrong: "rgba(79,110,232,.52)",
shadow: "rgba(15,23,42,.18)",
},
// 时间线/字幕组件语义色:轨道底、按类型区分的片段、标尺与播放头、字幕列表条目态。
timeline: {
trackFill: "#f6f8fb",
trackBorder: "rgba(17,24,39,.08)",
clipVideo: "rgba(79,110,232,.16)",
clipAudio: "rgba(16,185,129,.14)",
clipSubtitle: "rgba(245,158,11,.16)",
clipSelectedBorder: "#4f6ee8",
handle: "rgba(255,255,255,.72)",
rulerTick: "rgba(17,24,39,.28)",
rulerLabel: "#6b7280",
playhead: "#4f6ee8",
entryActive: "rgba(79,110,232,.10)",
entryHover: "rgba(17,24,39,.05)",
},
accent: {
primary: "#4f6ee8",
primarySoft: "rgba(79,110,232,.14)",
@@ -90,6 +105,20 @@ export const canvasThemes = {
glowStrong: "rgba(143,155,214,.48)",
shadow: "rgba(0,0,0,.6)",
},
timeline: {
trackFill: "#101114",
trackBorder: "rgba(255,255,255,.08)",
clipVideo: "rgba(96,126,234,.22)",
clipAudio: "rgba(16,185,129,.20)",
clipSubtitle: "rgba(245,158,11,.20)",
clipSelectedBorder: "#8f9bd6",
handle: "rgba(255,255,255,.5)",
rulerTick: "rgba(255,255,255,.26)",
rulerLabel: "#a3a3a3",
playhead: "#8f9bd6",
entryActive: "rgba(96,126,234,.16)",
entryHover: "rgba(255,255,255,.06)",
},
accent: {
primary: "#8f9bd6",
primarySoft: "rgba(107,120,190,.2)",

View File

@@ -10,7 +10,7 @@ export type MergeVideoProgress = { phase: "loading" | "reading" | "encoding"; pr
let ffmpegPromise: Promise<FFmpeg> | null = null;
// ffmpeg 只在用户明确合并视频时加载,避免把 wasm 和 worker 放进画布首屏包体。
async function loadFFmpeg(onProgress?: (progress: MergeVideoProgress) => void) {
export async function loadFFmpeg(onProgress?: (progress: MergeVideoProgress) => void) {
if (!ffmpegPromise) {
ffmpegPromise = (async () => {
const ffmpeg = new FFmpeg();

View File

@@ -1,4 +1,4 @@
import { Download, FolderPlus, GalleryHorizontalEnd, Image as ImageIcon, Info, LoaderCircle, Lock, Maximize2, MessageSquare, Minus, Music2, Plus, RefreshCw, Settings2, Trash2, Unlock, Upload, UserRound, Video } from "lucide-react";
import { Captions, Clapperboard, Download, FolderPlus, GalleryHorizontalEnd, Image as ImageIcon, Info, LoaderCircle, Lock, Maximize2, MessageSquare, Minus, Music2, Plus, RefreshCw, Settings2, Trash2, Unlock, Upload, UserRound, Video } from "lucide-react";
import { CONTENT_MODERATION_ERROR_CODE, isContentModerationError } from "@/lib/generation-error";
import { registerToolbarTools, type ToolContext, type ToolDefinition } from "@/lib/canvas/tool-registry";
@@ -193,6 +193,30 @@ export const nodeHoverToolbarTools: ToolDefinition[] = [
applicable: isVideo,
run: (ctx) => ctx.handlers.onNodeUpload(ctx.node!),
},
{
id: "subtitles",
toolbar: "node-hover",
category: "node-state",
label: "编辑字幕",
displayLabel: "字幕",
icon: <Captions className="size-3.5" />,
defaultVisible: true,
defaultOrder: 145,
applicable: (ctx) => hasVideo(ctx),
run: (ctx) => ctx.handlers.onNodeSubtitles(ctx.node!),
},
{
id: "timeline",
toolbar: "node-hover",
category: "node-state",
label: "时间线编辑",
displayLabel: "时间线",
icon: <Clapperboard className="size-3.5" />,
defaultVisible: true,
defaultOrder: 148,
applicable: (ctx) => hasVideo(ctx) || hasAudio(ctx),
run: (ctx) => ctx.handlers.onNodeTimeline(ctx.node!),
},
{
id: "uploadAudio",
toolbar: "node-hover",

View File

@@ -81,6 +81,8 @@ export type ToolbarHandlers = {
onNodeAngle: (node: CanvasNodeData) => void;
onNodeViewImage: (node: CanvasNodeData) => void;
onNodeExtractVideoLastFrame: (node: CanvasNodeData) => void;
onNodeSubtitles: (node: CanvasNodeData) => void;
onNodeTimeline: (node: CanvasNodeData) => void;
onNodeReversePrompt: (node: CanvasNodeData) => void;
onNodeToggleFreeResize: (node: CanvasNodeData) => void;
onNodeToggleLocked: (node: CanvasNodeData) => void;

View File

@@ -0,0 +1,67 @@
import type { SrtEntry } from "@/types/timeline";
function timeToMs(timestamp: string): number {
const [hours, minutes, secondsAndMs] = timestamp.split(":");
const [seconds, milliseconds] = secondsAndMs.split(",");
return Number.parseInt(hours, 10) * 3_600_000 + Number.parseInt(minutes, 10) * 60_000 + Number.parseInt(seconds, 10) * 1_000 + Number.parseInt(milliseconds, 10);
}
export function parseSrt(content: string): SrtEntry[] {
if (!content.trim()) {
return [];
}
const blocks = content.trim().split(/\r?\n\s*\r?\n/);
const entries: SrtEntry[] = [];
for (const block of blocks) {
const lines = block.trim().split(/\r?\n/);
if (lines.length < 3) {
continue;
}
const index = Number.parseInt(lines[0], 10);
if (Number.isNaN(index)) {
continue;
}
const match = lines[1].match(/\d{2}:\d{2}:\d{2},\d{3}\s*-->\s*\d{2}:\d{2}:\d{2},\d{3}/);
if (!match) {
continue;
}
const [start, end] = match[0].split(/\s*-->\s*/);
entries.push({
index,
startMs: timeToMs(start),
endMs: timeToMs(end),
text: lines.slice(2).join("\n"),
});
}
return entries;
}
function msToTimestamp(ms: number): string {
const safeMs = Math.max(0, Math.round(ms));
const hours = Math.floor(safeMs / 3_600_000);
const minutes = Math.floor((safeMs % 3_600_000) / 60_000);
const seconds = Math.floor((safeMs % 60_000) / 1_000);
const milliseconds = safeMs % 1_000;
return `${String(hours).padStart(2, "0")}:` + `${String(minutes).padStart(2, "0")}:` + `${String(seconds).padStart(2, "0")},` + `${String(milliseconds).padStart(3, "0")}`;
}
export function serializeSrtEntries(entries: SrtEntry[]): string {
if (entries.length === 0) {
return "";
}
return (
entries
.map((entry, idx) => {
const index = Number.isInteger(entry.index) && entry.index > 0 ? entry.index : idx + 1;
return `${index}\n${msToTimestamp(entry.startMs)} --> ${msToTimestamp(entry.endMs)}\n${entry.text}`;
})
.join("\n\n") + "\n"
);
}

View File

@@ -0,0 +1,131 @@
import type { SrtEntry } from "@/types/timeline";
import { serializeSrtEntries } from "./srt-parser";
export const DEFAULT_MAX_CHARS_PER_ENTRY = 35;
export const MIN_CHARS_PER_ENTRY = 20;
export const MAX_CHARS_PER_ENTRY_LIMIT = 60;
export const MIN_SEGMENT_DURATION_MS = 300;
const CJK_PUNCTUATION = ",。;!?、:";
const LATIN_PUNCTUATION = ",.;!?:";
/**
* 在 text 中为 targetLen 附近找最佳断点。
* 返回切分位置 cut(前段为 text.slice(0, cut),后段为 text.slice(cut))。
* 优先级:中文标点 > 英文标点 > 空格 > 硬切 targetLen。
* 在同一优先级内选最靠右的位置(靠近 targetLen)。
*/
export function findBestBreakPoint(text: string, targetLen: number): number {
if (text.length <= targetLen) {
return text.length;
}
const windowStart = Math.max(1, Math.floor(targetLen * 0.6));
const windowEnd = targetLen;
// 优先级 1:中文标点 — 从右向左扫描,最靠右优先
for (let i = windowEnd - 1; i >= windowStart - 1; i -= 1) {
if (CJK_PUNCTUATION.includes(text[i])) {
return i + 1;
}
}
// 优先级 2:英文标点
for (let i = windowEnd - 1; i >= windowStart - 1; i -= 1) {
if (LATIN_PUNCTUATION.includes(text[i])) {
return i + 1;
}
}
// 优先级 3:空格
for (let i = windowEnd - 1; i >= windowStart - 1; i -= 1) {
if (text[i] === " ") {
return i + 1;
}
}
// 优先级 4:硬切
return targetLen;
}
/**
* 对已切分好的段列表应用最小时长约束(MIN_SEGMENT_DURATION_MS)。
* 逐段检查:若某段时长不足,将其 endMs 向后推,同时把下一段的 startMs 相应推移。
* 最后一段的 endMs 始终等于原始 entry.endMs,不做调整。
* 若总时长本身不足以满足所有段的最小时长,接受违规(不崩溃,不重排)。
*/
function enforceMinDuration(segments: SrtEntry[], originalEndMs: number): SrtEntry[] {
if (segments.length <= 1) {
return segments;
}
const result = segments.map((s) => ({ ...s }));
for (let i = 0; i < result.length - 1; i++) {
const dur = result[i].endMs - result[i].startMs;
if (dur < MIN_SEGMENT_DURATION_MS) {
const needed = MIN_SEGMENT_DURATION_MS - dur;
// 最多能推到下一段结束(即 originalEndMs 对于最后两段),但不超过 originalEndMs
const cap = Math.min(result[i + 1].endMs, originalEndMs);
const newEnd = Math.min(result[i].endMs + needed, cap);
result[i].endMs = newEnd;
result[i + 1].startMs = newEnd;
}
}
// 确保末段 endMs 不变
result[result.length - 1].endMs = originalEndMs;
return result;
}
/**
* 把一个超长 entry 切成若干不超过 maxChars 的子 entry,递归切分。
* 时间按字符数等比分配,最终应用 MIN_SEGMENT_DURATION_MS 下限。
*/
export function splitLongEntry(entry: SrtEntry, maxChars: number): SrtEntry[] {
if (entry.text.length <= maxChars) {
return [entry];
}
const cut = findBestBreakPoint(entry.text, maxChars);
let frontText = entry.text.slice(0, cut).replace(/\s+$/, "");
let backText = entry.text.slice(cut).replace(/^\s+/, "");
if (frontText.length === 0 || backText.length === 0) {
// 退化情况:空白裁剪后一侧为空,回退到硬切
frontText = entry.text.slice(0, maxChars);
backText = entry.text.slice(maxChars);
}
const totalLen = frontText.length + backText.length;
const durationMs = entry.endMs - entry.startMs;
const frontDuration = Math.round((durationMs * frontText.length) / totalLen);
const splitPointMs = entry.startMs + frontDuration;
const frontEntry: SrtEntry = {
index: entry.index,
startMs: entry.startMs,
endMs: splitPointMs,
text: frontText,
};
const backEntry: SrtEntry = {
index: entry.index,
startMs: splitPointMs,
endMs: entry.endMs,
text: backText,
};
const frontSegments = frontText.length > maxChars ? splitLongEntry(frontEntry, maxChars) : [frontEntry];
const backSegments = backText.length > maxChars ? splitLongEntry(backEntry, maxChars) : [backEntry];
const segments = [...frontSegments, ...backSegments];
return enforceMinDuration(segments, entry.endMs);
}
/**
* 遍历 entries,对每条超长的调用 splitLongEntry,最后重新编号 index 为 1..N。
*/
export function resegmentSrtEntries(entries: SrtEntry[], maxChars: number): SrtEntry[] {
const split: SrtEntry[] = [];
for (const entry of entries) {
split.push(...splitLongEntry(entry, maxChars));
}
return split.map((entry, idx) => ({ ...entry, index: idx + 1 }));
}

View File

@@ -0,0 +1,53 @@
import type { SrtEntry } from "@/types/timeline";
export interface SubtitleHighlightLLMResult {
entryIndex: number;
shouldHighlight: boolean;
highlightText: string;
start: number;
end: number;
}
export function buildSubtitleHighlightSystemPrompt(): string {
return `你是一个视频字幕关键词高亮助手。请输出严格 JSON。
任务目标:
- 为每条字幕判断是否需要高亮
- 每条字幕最多高亮 1 段
- 只返回最值得高亮的一个关键词或短语
- start 和 end 使用 JavaScript 字符串下标
- end 为 exclusive
- 没有明确重点时返回 shouldHighlight=false
高亮标准:
- 优先高亮结论词、数字、身份词、首次/突破词、强反差词
- 不要高亮虚词、口头语、整句长短语
边界约束:
- 不负责颜色、字号、位置、动效
- 只返回 highlights 数组
- highlightText 必须严格等于原字幕文本切片结果`;
}
export function buildSubtitleHighlightUserMessage(entries: SrtEntry[]): string {
const lines = entries.map((entry) => {
return `entryIndex=${entry.index} | startMs=${entry.startMs} | endMs=${entry.endMs} | text=${entry.text}`;
});
return `请根据以下字幕和上下文,为每条字幕判断是否需要高亮,并返回 JSON:
{
"highlights": [
{
"entryIndex": 1,
"shouldHighlight": true,
"highlightText": "示例",
"start": 0,
"end": 2
}
]
}
字幕列表:
${lines.join("\n")}`;
}

View File

@@ -0,0 +1,113 @@
import { runBackendCanvasGenerationTask } from "@/lib/canvas/canvas-project-generation";
import type { AiConfig } from "@/stores/use-config-store";
import type { SrtEntry, SubtitleHighlight } from "@/types/timeline";
import { buildSubtitleHighlightSystemPrompt, buildSubtitleHighlightUserMessage } from "./subtitle-highlight-ai";
import { parseSubtitleHighlightResponse } from "./subtitle-highlight-service";
export interface SubtitleHighlightProgress {
batchIndex: number;
batchTotal: number;
processedEntries: number;
totalEntries: number;
percent: number;
}
export type SubtitleHighlightRunnerOptions = {
projectId: string;
/** 画布节点 id,用于任务元数据标记来源 */
nodeId: string;
config: AiConfig;
/** 每个 batch 的字幕条数。默认 30 */
batchSize?: number;
/** 同时进行的 batch 数。默认 3;LLM 请求是 I/O 密集型,3-4 并行通常稳定低于 provider 限速 */
concurrency?: number;
signal?: AbortSignal;
onProgress?: (progress: SubtitleHighlightProgress) => void;
};
export async function generateSubtitleHighlights(entries: SrtEntry[], options: SubtitleHighlightRunnerOptions): Promise<SubtitleHighlight[]> {
if (entries.length === 0) {
return [];
}
const batchSize = Math.max(1, options.batchSize ?? 30);
const concurrency = Math.max(1, Math.floor(options.concurrency ?? 3));
const systemPrompt = buildSubtitleHighlightSystemPrompt();
// 切批:保留原顺序,每个 batch 独立可并行
const batches: { index: number; entries: SrtEntry[] }[] = [];
for (let i = 0; i < entries.length; i += batchSize) {
batches.push({ index: batches.length, entries: entries.slice(i, i + batchSize) });
}
const batchTotal = batches.length;
const results: SubtitleHighlight[][] = new Array(batchTotal).fill(null).map(() => []);
let processedEntries = 0;
options.onProgress?.({
batchIndex: 0,
batchTotal,
processedEntries: 0,
totalEntries: entries.length,
percent: 0,
});
// worker 池模式:从共享游标里抢任务,保证 concurrency 个 batch 同时跑
let cursor = 0;
const firstError: { current: unknown } = { current: null };
const runOne = async (): Promise<void> => {
while (true) {
if (firstError.current || options.signal?.aborted) return;
const myIndex = cursor;
cursor += 1;
if (myIndex >= batches.length) return;
const batch = batches[myIndex];
try {
const result = await runBackendCanvasGenerationTask({
projectId: options.projectId,
nodeId: `${options.nodeId}:subtitle-highlight:${myIndex + 1}/${batchTotal}`,
mode: "text",
prompt: `${systemPrompt}\n\n${buildSubtitleHighlightUserMessage(batch.entries)}`,
config: options.config,
signal: options.signal,
metadata: { operation: "subtitle_highlight", batchIndex: myIndex, batchTotal },
});
results[myIndex] = parseSubtitleHighlightResponse(parseJsonObject(result.text || ""), batch.entries);
processedEntries += batch.entries.length;
options.onProgress?.({
batchIndex: myIndex + 1,
batchTotal,
processedEntries,
totalEntries: entries.length,
percent: Math.round((processedEntries / entries.length) * 100),
});
} catch (err) {
if (!firstError.current) firstError.current = err;
return;
}
}
};
await Promise.all(Array.from({ length: Math.min(concurrency, batchTotal) }, () => runOne()));
if (options.signal?.aborted) {
throw new DOMException("Aborted", "AbortError");
}
if (firstError.current) {
const message = firstError.current instanceof Error ? firstError.current.message : "未知错误";
throw new Error(`字幕关键词高亮生成失败:${message}`);
}
return results.flat();
}
function parseJsonObject(text: string): unknown {
if (!text?.trim()) {
return null;
}
try {
return JSON.parse(text);
} catch {
return null;
}
}

View File

@@ -0,0 +1,44 @@
import type { SrtEntry, SubtitleHighlight } from "@/types/timeline";
import { filterValidSubtitleHighlights } from "./subtitle-highlights";
import type { SubtitleHighlightLLMResult } from "./subtitle-highlight-ai";
function isSubtitleHighlightLLMResult(value: unknown): value is SubtitleHighlightLLMResult {
if (!value || typeof value !== "object") {
return false;
}
const candidate = value as Record<string, unknown>;
return Number.isFinite(candidate.entryIndex) && typeof candidate.shouldHighlight === "boolean" && typeof candidate.highlightText === "string" && Number.isFinite(candidate.start) && Number.isFinite(candidate.end);
}
export function parseSubtitleHighlightResponse(payload: unknown, entries: SrtEntry[]): SubtitleHighlight[] {
if (!payload || typeof payload !== "object") {
return [];
}
const rawHighlights = Array.isArray((payload as { highlights?: unknown[] }).highlights) ? (payload as { highlights: unknown[] }).highlights : [];
const entryMap = new Map(entries.map((entry) => [entry.index, entry]));
const highlights = rawHighlights.flatMap((item) => {
if (!isSubtitleHighlightLLMResult(item) || !item.shouldHighlight) {
return [];
}
const entry = entryMap.get(item.entryIndex);
if (!entry) {
return [];
}
return [
{
entryIndex: item.entryIndex,
start: item.start,
end: item.end,
highlightText: item.highlightText,
sourceText: entry.text,
},
];
});
return filterValidSubtitleHighlights(entries, highlights);
}

View File

@@ -0,0 +1,91 @@
import type { SrtEntry, SubtitleHighlight } from "@/types/timeline";
export function isValidSubtitleHighlight(entry: SrtEntry | undefined, highlight: SubtitleHighlight): boolean {
if (!entry || entry.index !== highlight.entryIndex) {
return false;
}
if (highlight.start < 0 || highlight.end <= highlight.start) {
return false;
}
if (highlight.end > highlight.sourceText.length) {
return false;
}
return highlight.highlightText === highlight.sourceText.slice(highlight.start, highlight.end);
}
export function isExpiredSubtitleHighlight(entry: SrtEntry | undefined, highlight: SubtitleHighlight): boolean {
if (!entry || entry.index !== highlight.entryIndex) {
return true;
}
return entry.text !== highlight.sourceText;
}
export function filterValidSubtitleHighlights(entries: SrtEntry[], highlights: SubtitleHighlight[]): SubtitleHighlight[] {
const entryMap = new Map(entries.map((entry) => [entry.index, entry]));
return highlights.filter((highlight) => {
const entry = entryMap.get(highlight.entryIndex);
return isValidSubtitleHighlight(entry, highlight) && !isExpiredSubtitleHighlight(entry, highlight);
});
}
/**
* 在重分段后,把旧高亮映射到新条目上。
* 规则:highlightText 必须作为子串出现在某个新条目的 text 中;
* 找到第一个匹配的条目,更新 entryIndex 和 start/end;
* 找不到则放入 dropped(通常是跨切分点的关键词)。
*/
export function remapHighlightsAfterResegment(oldHighlights: SubtitleHighlight[], newEntries: SrtEntry[]): { remapped: SubtitleHighlight[]; dropped: SubtitleHighlight[] } {
const remapped: SubtitleHighlight[] = [];
const dropped: SubtitleHighlight[] = [];
for (const highlight of oldHighlights) {
const target = newEntries.find((entry) => entry.text.includes(highlight.highlightText));
if (!target) {
dropped.push(highlight);
continue;
}
const start = target.text.indexOf(highlight.highlightText);
remapped.push({
entryIndex: target.index,
start,
end: start + highlight.highlightText.length,
highlightText: highlight.highlightText,
sourceText: target.text,
});
}
return { remapped, dropped };
}
// 无可用大模型配置时的本地回退:按终止标点取首段作为高亮,保证功能不中断。
const TERMINAL_PUNCTUATION = "。!?;";
export function buildFallbackHighlights(entries: SrtEntry[]): SubtitleHighlight[] {
const highlights: SubtitleHighlight[] = [];
for (const entry of entries) {
let terminalIndex = -1;
for (let i = 0; i < entry.text.length; i += 1) {
if (TERMINAL_PUNCTUATION.includes(entry.text[i])) {
terminalIndex = i;
break;
}
}
if (terminalIndex <= 0) {
continue;
}
const end = terminalIndex + 1;
highlights.push({
entryIndex: entry.index,
start: 0,
end,
highlightText: entry.text.slice(0, end),
sourceText: entry.text,
});
}
return highlights;
}

View File

@@ -0,0 +1,184 @@
// 从画布节点构建时间线数据:视频/音频节点自动入轨,字幕条目转成字幕片段。
// 构建是单向快照:时间线保存后用户可自由拖拽,重新构建不覆盖已有时间线。
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(),
};
}

View File

@@ -0,0 +1,93 @@
// 第三期:时间线导出运行时。把 buildTimelineRenderPlan 产出的步骤逐步在 ffmpeg.wasm 中执行,
// 复用 canvas-video-merge.ts 的 loadFFmpeg(本地同源加载 core/wasm),媒体读写与清理逻辑保持一致。
import { fetchFile } from "@ffmpeg/util";
import { loadFFmpeg } from "@/lib/canvas/canvas-video-merge";
import { getMediaBlob } from "@/services/file-storage";
import type { TimelineProject } from "@/types/timeline";
import { SUBTITLE_FILE, buildSubtitleSrt, buildTimelineRenderPlan, getOrderedSubtitleClips, type TimelineRenderContext, type TimelineRenderSource } from "./timeline-to-ffmpeg";
export type TimelineExportProgress = {
phase: "loading" | "reading" | "encoding";
percent: number;
detail: string;
};
export type TimelineExportOptions = {
onProgress?: (progress: TimelineExportProgress) => void;
context?: Partial<TimelineRenderContext>;
};
async function fetchSourceBlob(source: TimelineRenderSource): Promise<Blob> {
if (source.storageKey) {
const stored = await getMediaBlob(source.storageKey);
if (stored) return stored;
}
if (source.url) {
const response = await fetch(source.url);
if (!response.ok) throw new Error("视频资源请求失败(" + response.status + ")");
return response.blob();
}
throw new Error("找不到素材 " + source.nodeId + " 的媒体文件");
}
/** 导出时间线为 MP4:写媒体 → 按计划逐步执行 → 返回 Blob(下载由调用方处理) */
export async function exportTimelineToMp4(timeline: TimelineProject, sources: TimelineRenderSource[], options: TimelineExportOptions = {}): Promise<Blob> {
const { onProgress, context } = options;
onProgress?.({ phase: "loading", percent: 0, detail: "加载 FFmpeg" });
const ffmpeg = await loadFFmpeg(({ phase, progress }) => onProgress?.({ phase: phase === "loading" ? "loading" : "reading", percent: progress, detail: phase === "loading" ? "加载 FFmpeg" : "读取素材" }));
const plan = buildTimelineRenderPlan(timeline, sources, context);
const writtenFiles = new Set<string>();
try {
for (const source of sources) {
onProgress?.({ phase: "reading", percent: 5, detail: "读取素材 " + source.fileName });
const blob = await fetchSourceBlob(source);
await ffmpeg.writeFile(source.fileName, await fetchFile(blob));
writtenFiles.add(source.fileName);
}
const executableSteps = plan.steps.filter((step) => step.kind !== "subtitle");
let stepIndex = 0;
for (const step of plan.steps) {
if (step.kind === "subtitle") {
const srt = buildSubtitleSrt(getOrderedSubtitleClips(timeline));
if (srt) {
await ffmpeg.writeFile(SUBTITLE_FILE, new TextEncoder().encode(srt));
writtenFiles.add(SUBTITLE_FILE);
}
continue;
}
if (step.args.includes("concat.txt")) {
const content = plan.concatEntries.map((file) => "file '" + file + "'").join("\n");
await ffmpeg.writeFile("concat.txt", new TextEncoder().encode(content));
writtenFiles.add("concat.txt");
}
onProgress?.({ phase: "encoding", percent: Math.round(10 + (stepIndex / Math.max(1, executableSteps.length)) * 75), detail: step.description });
if (step.kind === "burn") {
const concatOutput = step.args[step.args.indexOf("-i") + 1];
try {
const exitCode = await ffmpeg.exec(["-y", ...step.args]);
if (exitCode !== 0) throw new Error("burn exit " + exitCode);
} catch {
// 当前 @ffmpeg/core 可能不含 libass(subtitles 滤镜),回退为直接封装已拼接视频。
onProgress?.({ phase: "encoding", percent: 92, detail: "字幕烧录不可用,回退为无字幕导出" });
const exitCode = await ffmpeg.exec(["-y", "-i", concatOutput, "-c", "copy", plan.finalOutput]);
if (exitCode !== 0) throw new Error("导出失败:字幕烧录与回退均失败");
}
} else {
const exitCode = await ffmpeg.exec(["-y", ...step.args]);
if (exitCode !== 0) throw new Error("导出失败:" + step.description);
}
writtenFiles.add(step.output);
stepIndex += 1;
}
const output = await ffmpeg.readFile(plan.finalOutput);
onProgress?.({ phase: "encoding", percent: 100, detail: "导出完成" });
return new Blob([output as BlobPart], { type: "video/mp4" });
} finally {
await Promise.all([...writtenFiles, "concat.txt", plan.finalOutput].map((file) => ffmpeg.deleteFile(file).catch(() => undefined)));
}
}

View File

@@ -0,0 +1,196 @@
// 时间线放置算法(移植自 lingji-cut 的 timeline-placement.ts)。
// 提供同轨碰撞检测、最近可用位置查找、跨轨放置与时长钳制。
import type { TimelineClip, TimelineTrack } from "@/types/timeline";
export type PlacementResult = {
startMs: number;
fits: boolean;
};
export type FindNearestArgs = {
targetStartMs: number;
durationMs: number;
trackId: string;
excludeClipId?: string;
clips: TimelineClip[];
};
export type PlacementTrackResult = {
trackId: string | null;
startMs: number;
};
export type FindAvailableTrackArgs = {
targetStartMs: number;
durationMs: number;
excludeClipId?: string;
clips: TimelineClip[];
tracks: TimelineTrack[];
};
export type ClampDurationArgs = {
clipId: string;
startMs: number;
requestedDurationMs: number;
trackId: string;
clips: TimelineClip[];
maxDurationMs?: number;
};
/** 半开区间 [startMs, startMs + durationMs) 重叠判断 */
export function clipsOverlap(left: { startMs: number; durationMs: number }, right: { startMs: number; durationMs: number }): boolean {
return left.startMs < right.startMs + right.durationMs && right.startMs < left.startMs + left.durationMs;
}
export type CanPlaceAtArgs = {
trackId: string;
startMs: number;
durationMs: number;
excludeClipId?: string;
clips: TimelineClip[];
};
export type CanPlaceAtResult = {
ok: boolean;
reason?: "overlap";
};
/**
* 判断指定轨道的区间 [startMs, startMs+durationMs) 是否可放置,
* 不做任何自动 snap/偏移;遇到任何同轨片段重叠即返回 ok=false。
*/
export function canPlaceAt(args: CanPlaceAtArgs): CanPlaceAtResult {
const { trackId, startMs, durationMs, excludeClipId, clips } = args;
const candidate = { startMs, durationMs };
for (const other of clips) {
if (other.trackId !== trackId) continue;
if (other.id === excludeClipId) continue;
if (clipsOverlap(candidate, other)) {
return { ok: false, reason: "overlap" };
}
}
return { ok: true };
}
/** 返回与候选区间碰撞的所有片段(按现有顺序,不排序)。 */
export function findCollidingItems(args: CanPlaceAtArgs): TimelineClip[] {
const { trackId, startMs, durationMs, excludeClipId, clips } = args;
const candidate = { startMs, durationMs };
return clips.filter((c) => c.trackId === trackId && c.id !== excludeClipId && clipsOverlap(candidate, c));
}
function getSortedClipsOnTrack(trackId: string, clips: TimelineClip[], excludeClipId?: string): TimelineClip[] {
return clips.filter((c) => c.trackId === trackId && c.id !== excludeClipId).sort((a, b) => a.startMs - b.startMs);
}
/** 在指定轨道上寻找离 targetStartMs 最近的可用放置位置 */
export function findNearestAvailablePlacement(args: FindNearestArgs): PlacementResult {
const { targetStartMs, durationMs, trackId, excludeClipId, clips } = args;
const managed = getSortedClipsOnTrack(trackId, clips, excludeClipId);
if (managed.length === 0) {
return { startMs: targetStartMs, fits: true };
}
const candidate = { startMs: targetStartMs, durationMs };
const hasConflict = managed.some((c) => clipsOverlap(candidate, c));
if (!hasConflict) {
return { startMs: targetStartMs, fits: true };
}
// 目标位置有冲突,扫描所有间隙寻找最近的可用位置:
// 第一个片段之前、片段之间、最后一个片段之后(无限大)。
type Gap = { start: number; end: number };
const gaps: Gap[] = [];
if (managed[0].startMs > 0) {
gaps.push({ start: 0, end: managed[0].startMs });
}
for (let i = 0; i < managed.length - 1; i++) {
const gapStart = managed[i].startMs + managed[i].durationMs;
const gapEnd = managed[i + 1].startMs;
if (gapEnd > gapStart) {
gaps.push({ start: gapStart, end: gapEnd });
}
}
const lastEnd = managed[managed.length - 1].startMs + managed[managed.length - 1].durationMs;
gaps.push({ start: lastEnd, end: Number.POSITIVE_INFINITY });
let bestStart: number | null = null;
let bestDistance = Number.POSITIVE_INFINITY;
for (const gap of gaps) {
const gapSize = gap.end - gap.start;
if (gapSize < durationMs) continue;
let candidateStart: number;
if (targetStartMs >= gap.start && targetStartMs + durationMs <= gap.end) {
candidateStart = targetStartMs;
} else if (targetStartMs < gap.start) {
candidateStart = gap.start;
} else {
candidateStart = Math.max(gap.start, gap.end - durationMs);
}
if (candidateStart + durationMs > gap.end && gap.end !== Number.POSITIVE_INFINITY) {
continue;
}
const distance = Math.abs(candidateStart - targetStartMs);
if (distance < bestDistance) {
bestDistance = distance;
bestStart = candidateStart;
}
}
if (bestStart !== null) {
return { startMs: bestStart, fits: true };
}
return { startMs: targetStartMs, fits: false };
}
/** 在所有可放置的视觉/音轨中寻找可放置的轨道(按 order 升序) */
export function findAvailableTrack(args: FindAvailableTrackArgs): PlacementTrackResult {
const { targetStartMs, durationMs, excludeClipId, clips, tracks } = args;
const candidates = tracks.filter((t) => t.kind === "video" || t.kind === "audio").sort((a, b) => a.order - b.order);
for (const track of candidates) {
const result = findNearestAvailablePlacement({
targetStartMs,
durationMs,
trackId: track.id,
excludeClipId,
clips,
});
if (result.fits) {
return { trackId: track.id, startMs: result.startMs };
}
}
return { trackId: null, startMs: targetStartMs };
}
/** 根据相邻片段和最大时长限制,钳制片段时长 */
export function clampClipDurationByNeighbors(args: ClampDurationArgs): number {
const { clipId, startMs, requestedDurationMs, trackId, clips, maxDurationMs } = args;
const managed = getSortedClipsOnTrack(trackId, clips, clipId);
let maxGap = requestedDurationMs;
for (const c of managed) {
if (c.startMs > startMs) {
const gap = c.startMs - startMs;
maxGap = Math.min(maxGap, gap);
break;
}
}
let result = Math.min(requestedDurationMs, maxGap);
if (maxDurationMs !== undefined) {
result = Math.min(result, maxDurationMs);
}
return Math.max(0, result);
}

View File

@@ -0,0 +1,61 @@
// 时间线吸附算法(移植自 lingji-cut 的 timeline-snap.ts)。
// 候选时间点吸附到播放头或其它片段边缘(起点/终点),取阈值内最近目标。
import type { TimelineClip } from "@/types/timeline";
export type SnapTargetKind = "playhead" | "clip-edge";
export type SnapTarget = {
ms: number;
kind: SnapTargetKind;
};
export type ComputeSnapArgs = {
candidateMs: number;
playheadMs: number;
clips: TimelineClip[];
excludeClipId?: string;
pxPerMs: number;
thresholdPx: number;
enabled: boolean;
};
export type ComputeSnapResult = {
snappedMs: number;
targets: SnapTarget[];
};
export function computeSnap(args: ComputeSnapArgs): ComputeSnapResult {
const { candidateMs, playheadMs, clips, excludeClipId, pxPerMs, thresholdPx, enabled } = args;
if (!enabled) {
return { snappedMs: candidateMs, targets: [] };
}
const thresholdMs = thresholdPx / Math.max(pxPerMs, 1e-6);
const candidates: SnapTarget[] = [];
if (Math.abs(candidateMs - playheadMs) <= thresholdMs) {
candidates.push({ ms: playheadMs, kind: "playhead" });
}
for (const clip of clips) {
if (clip.id === excludeClipId) continue;
const start = clip.startMs;
const end = clip.startMs + clip.durationMs;
if (Math.abs(candidateMs - start) <= thresholdMs) {
candidates.push({ ms: start, kind: "clip-edge" });
}
if (Math.abs(candidateMs - end) <= thresholdMs) {
candidates.push({ ms: end, kind: "clip-edge" });
}
}
if (candidates.length === 0) {
return { snappedMs: candidateMs, targets: [] };
}
candidates.sort((a, b) => Math.abs(a.ms - candidateMs) - Math.abs(b.ms - candidateMs));
const chosen = candidates[0];
const sameMsTargets = candidates.filter((t) => t.ms === chosen.ms);
return { snappedMs: chosen.ms, targets: sameMsTargets };
}

View File

@@ -0,0 +1,204 @@
// 第三期:时间线 → FFmpeg 命令序列的纯函数规划层。
// 不直接调用 FFmpeg,只产出可执行的参数计划,方便单测与运行时逐步执行;
// 运行时负责把媒体源写入 ffmpeg 工作区、写 SRT、执行 args 并清理文件。
// 数据流:TimelineProject + 节点媒体 → trim(按 sourceStart/sourceDuration 裁切)→ 黑场补齐空隙 → concat → 字幕 SRT → 烧录。
import type { TimelineClip, TimelineProject } from "@/types/timeline";
export type TimelineRenderSource = {
nodeId: string;
/** 已写入 ffmpeg 工作区的文件名(如 input-0.mp4) */
fileName: string;
durationMs: number;
/** 媒体定位(运行时用):本地缓存 storageKey 或远程资源地址,至少提供一个 */
storageKey?: string;
url?: string;
};
export type TimelineRenderStep = {
kind: "trim" | "gap" | "concat" | "subtitle" | "burn";
/** 本步骤输出文件名 */
output: string;
/** ffmpeg 参数数组(不含可执行文件名与 -y 覆盖参数) */
args: string[];
description: string;
/** 该步骤依赖 libass(subtitles 滤镜);当前 ffmpeg.wasm 内核可能不含,运行时需探测回退 */
requiresLibass?: boolean;
};
export type TimelineRenderContext = {
width: number;
height: number;
fps: number;
/** 是否烧录字幕;false 时跳过 burn 步骤 */
burnSubtitles: boolean;
/** 最终输出文件名 */
outputName: string;
};
export type TimelineRenderPlan = {
steps: TimelineRenderStep[];
finalOutput: string;
/** concat 输入文件列表(trim/gap 输出),运行时据此写 concat.txt */
concatEntries: string[];
};
export const SUBTITLE_FILE = "timeline.srt";
export function getOrderedVideoClips(timeline: TimelineProject): TimelineClip[] {
return timeline.clips
.filter((clip) => clip.kind === "video")
.slice()
.sort((a, b) => a.startMs - b.startMs || a.trackId.localeCompare(b.trackId));
}
export function getOrderedSubtitleClips(timeline: TimelineProject): TimelineClip[] {
return timeline.clips
.filter((clip) => clip.kind === "subtitle")
.slice()
.sort((a, b) => a.startMs - b.startMs || a.trackId.localeCompare(b.trackId));
}
/** 毫秒 → SRT 时间码 hh:mm:ss,mmm */
export function formatSrtTimestamp(ms: number): string {
const safe = Math.max(0, Math.round(ms));
const pad = (value: number, length = 2) => String(value).padStart(length, "0");
const hours = Math.floor(safe / 3_600_000);
const minutes = Math.floor((safe % 3_600_000) / 60_000);
const seconds = Math.floor((safe % 60_000) / 1_000);
const millis = safe % 1_000;
return `${pad(hours)}:${pad(minutes)}:${pad(seconds)},${pad(millis, 3)}`;
}
/** 字幕轨片段 → SRT 文件内容(时间线全局时间,直接用于烧录) */
export function buildSubtitleSrt(clips: TimelineClip[]): string {
return getOrderedSubtitleClips({ version: 2, tracks: [], clips, durationMs: 0 })
.filter((clip) => clip.text && clip.durationMs > 0)
.map((clip, index) => {
const text = (clip.text || "").replace(/\r?\n/g, " ").trim();
return [String(index + 1), `${formatSrtTimestamp(clip.startMs)} --> ${formatSrtTimestamp(clip.startMs + clip.durationMs)}`, text].join("\n");
})
.join("\n\n");
}
function defaultContext(): TimelineRenderContext {
return { width: 1920, height: 1080, fps: 30, burnSubtitles: true, outputName: "export.mp4" };
}
/**
* 生成导出计划。
* 视频轨按 startMs 顺序裁切并补齐空隙(lavfi 黑场 + 静音),最后 concat;
* 字幕轨独立生成 SRT 并在末步烧录(libass 滤镜),音频轨暂按现有音频片段拼接(第三期迭代)。
*/
export function buildTimelineRenderPlan(timeline: TimelineProject, sources: TimelineRenderSource[], context: Partial<TimelineRenderContext> = {}): TimelineRenderPlan {
const cfg: TimelineRenderContext = { ...defaultContext(), ...context };
const sourceByNode = new Map(sources.map((item) => [item.nodeId, item]));
const steps: TimelineRenderStep[] = [];
const concatEntries: string[] = [];
const videoClips = getOrderedVideoClips(timeline);
// 1) 逐片段裁切:按源素材内部时间定位,输出统一编码便于 concat。
videoClips.forEach((clip, index) => {
const source = sourceByNode.get(clip.nodeId);
if (!source) return;
const output = `trim-${index}.mp4`;
// 裁切时长取时间线片段时长(clip.durationMs),而不是源素材剩余时长:
// 左缘裁剪后 sourceStartMs 前移但 sourceDurationMs 仍为源全长,若按源时长 -t 会把旧片段尾部多裁出来,
// 表现为「裁剪后播放仍从最原始视频开始/出现旧片段」;-ss 已定位源内起点,-t 必须等于片段展示时长。
const durationSec = Math.max(0.1, clip.durationMs / 1000);
steps.push({
kind: "trim",
output,
args: ["-ss", String((clip.sourceStartMs || 0) / 1000), "-i", source.fileName, "-t", String(durationSec), "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", "-c:a", "aac", "-b:a", "128k", output],
description: `裁切片段 ${index + 1}(${clip.title || clip.nodeId})`,
});
concatEntries.push(output);
});
// 2) 空隙补黑场(时间线与成片对齐),含静音音轨。
let cursorMs = 0;
videoClips.forEach((clip, index) => {
const gapMs = clip.startMs - cursorMs;
if (gapMs > 100) {
const output = `gap-${index}.mp4`;
const durationSec = gapMs / 1000;
steps.push({
kind: "gap",
output,
args: [
"-f",
"lavfi",
"-i",
`color=c=black:s=${cfg.width}x${cfg.height}:r=${cfg.fps}:d=${durationSec}`,
"-f",
"lavfi",
"-i",
"anullsrc=r=44100:cl=stereo",
"-t",
String(durationSec),
"-c:v",
"libx264",
"-preset",
"veryfast",
"-crf",
"20",
"-c:a",
"aac",
"-shortest",
output,
],
description: `补黑场 ${(gapMs / 1000).toFixed(2)}s`,
});
concatEntries.push(output);
}
cursorMs = clip.startMs + clip.durationMs;
});
// 3) concat 拼接视频轨。
const concatOutput = "timeline-video.mp4";
if (concatEntries.length) {
steps.push({
kind: "concat",
output: concatOutput,
args: ["-f", "concat", "-safe", "0", "-i", "concat.txt", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", "-c:a", "aac", concatOutput],
description: "拼接视频轨",
});
}
// 4) 字幕轨 → SRT 内容(运行时写入 SUBTITLE_FILE)。
const subtitleClips = getOrderedSubtitleClips(timeline);
if (cfg.burnSubtitles && subtitleClips.some((clip) => clip.text)) {
steps.push({
kind: "subtitle",
output: SUBTITLE_FILE,
args: [],
description: "生成字幕 SRT",
});
}
// 5) 烧录字幕并输出最终文件(subtitles 滤镜需要 libass)。
const finalOutput = cfg.outputName;
if (concatEntries.length && steps.some((step) => step.kind === "subtitle")) {
steps.push({
kind: "burn",
output: finalOutput,
args: ["-i", concatOutput, "-vf", `subtitles=${SUBTITLE_FILE}`, "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", "-c:a", "copy", finalOutput],
description: "烧录字幕并输出",
requiresLibass: true,
});
} else if (concatEntries.length) {
steps.push({
kind: "concat",
output: finalOutput,
args: ["-i", concatOutput, "-c", "copy", finalOutput],
description: "输出成片(无字幕)",
});
}
return { steps, finalOutput, concatEntries };
}
/** 供导出对话框/文档展示的人类可读命令预览 */
export function describeRenderPlan(plan: TimelineRenderPlan): string {
return plan.steps.map((step) => `# ${step.description}\nffmpeg -y ${step.args.join(" ")}`).join("\n\n");
}

View File

@@ -0,0 +1,54 @@
// 时间线轨道管理(移植自 lingji-cut 的 timeline-tracks.ts,按本项目类型精简)。
// 默认三轨:视频轨、音频轨、字幕轨;提供轨道查询与规范化。
import type { TimelineProject, TimelineTrack } from "@/types/timeline";
export const DEFAULT_VIDEO_TRACK_ID = "video-1";
export const DEFAULT_AUDIO_TRACK_ID = "audio-1";
export const DEFAULT_SUBTITLE_TRACK_ID = "subtitle-1";
export function createDefaultTracks(): TimelineTrack[] {
return [
{ id: DEFAULT_VIDEO_TRACK_ID, kind: "video", label: "视频 1", order: 0 },
{ id: DEFAULT_AUDIO_TRACK_ID, kind: "audio", label: "音频 1", order: 1 },
{ id: DEFAULT_SUBTITLE_TRACK_ID, kind: "subtitle", label: "字幕 1", order: 2 },
];
}
export function getVisualTracks(tracks: TimelineTrack[]): TimelineTrack[] {
return tracks.filter((track) => track.kind === "video" || track.kind === "image" || track.kind === "text").sort((a, b) => a.order - b.order || a.id.localeCompare(b.id));
}
export function getAudioTracks(tracks: TimelineTrack[]): TimelineTrack[] {
return tracks.filter((track) => track.kind === "audio").sort((a, b) => a.order - b.order || a.id.localeCompare(b.id));
}
export function getSubtitleTracks(tracks: TimelineTrack[]): TimelineTrack[] {
return tracks.filter((track) => track.kind === "subtitle").sort((a, b) => a.order - b.order || a.id.localeCompare(b.id));
}
export function getTracksByKind(tracks: TimelineTrack[], kind: TimelineTrack["kind"]): TimelineTrack[] {
return tracks.filter((track) => track.kind === kind).sort((a, b) => a.order - b.order || a.id.localeCompare(b.id));
}
/** 规范化时间线数据:轨道去重、补默认轨、时长取片段末端最大值。 */
export function normalizeTimelineProject(timeline: Partial<TimelineProject> | null | undefined): TimelineProject {
const rawTracks = Array.isArray(timeline?.tracks) ? timeline.tracks : [];
const seen = new Set<string>();
const deduped = rawTracks.filter((track) => {
if (seen.has(track.id)) return false;
seen.add(track.id);
return true;
});
const baseTracks = createDefaultTracks();
const tracks = [...baseTracks, ...deduped.filter((track) => !baseTracks.some((base) => base.id === track.id))];
const clips = Array.isArray(timeline?.clips) ? timeline.clips : [];
const durationMs = clips.reduce((max, clip) => Math.max(max, clip.startMs + clip.durationMs), 0);
return {
version: 2,
tracks,
clips,
durationMs,
updatedAt: timeline?.updatedAt,
};
}

View File

@@ -0,0 +1,67 @@
// 时间线视图数学(移植自 lingji-cut 的 timeline-view.ts,按本项目类型精简)。
// 提供缩放钳制、像素/毫秒换算、标尺刻度与时间格式化。
const BASE_TIMELINE_PX_PER_SECOND = 96;
const MIN_TIMELINE_TRACK_WIDTH = 960;
const MIN_TIMELINE_ZOOM = 0.02;
const MAX_TIMELINE_ZOOM = 4;
const TIMELINE_ZOOM_STEP = 1.25;
type ZoomDirection = "in" | "out";
function roundZoom(value: number): number {
return Math.round(value * 100) / 100;
}
export function clampTimelineZoom(zoomLevel: number): number {
return roundZoom(Math.min(MAX_TIMELINE_ZOOM, Math.max(MIN_TIMELINE_ZOOM, zoomLevel)));
}
export function getBaseTimelineWidth(durationMs: number): number {
return Math.max(MIN_TIMELINE_TRACK_WIDTH, Math.ceil(Math.max(1_000, durationMs) / 1_000) * BASE_TIMELINE_PX_PER_SECOND);
}
export function getNextTimelineZoom(zoomLevel: number, direction: ZoomDirection): number {
const nextZoom = direction === "in" ? zoomLevel * TIMELINE_ZOOM_STEP : zoomLevel / TIMELINE_ZOOM_STEP;
return clampTimelineZoom(nextZoom);
}
export function zoomIn(zoomLevel: number): number {
return getNextTimelineZoom(zoomLevel, "in");
}
export function zoomOut(zoomLevel: number): number {
return getNextTimelineZoom(zoomLevel, "out");
}
export function getFitTimelineZoom(durationMs: number, viewportWidth: number): number {
const safeViewportWidth = Math.max(320, viewportWidth);
return clampTimelineZoom(safeViewportWidth / getBaseTimelineWidth(durationMs));
}
export function getTimelineTrackWidth(durationMs: number, zoomLevel: number, viewportWidth: number): number {
const safeViewportWidth = Math.max(320, viewportWidth);
const zoomedWidth = Math.round(getBaseTimelineWidth(durationMs) * clampTimelineZoom(zoomLevel));
return Math.max(safeViewportWidth, zoomedWidth);
}
export function getTimelineVisualEndMs(clips: { startMs: number; durationMs: number }[]): number {
return clips.reduce((max, clip) => Math.max(max, clip.startMs + clip.durationMs), 0);
}
/** 标尺刻度间隔(毫秒),保证刻度间隔像素不小于 64px。 */
export function getRulerTickStep(pxPerMs: number): number {
const steps = [100, 250, 500, 1_000, 2_000, 5_000, 10_000, 15_000, 30_000, 60_000, 120_000, 300_000];
const target = 64 / Math.max(pxPerMs, 1e-6);
return steps.find((step) => step >= target) ?? steps[steps.length - 1];
}
/** 毫秒 → 标尺/播放头时间文案(mm:ss.d)。 */
export function formatTimelineTime(ms: number): string {
const safeMs = Math.max(0, Math.round(ms));
const totalSeconds = Math.floor(safeMs / 1_000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
const tenths = Math.floor((safeMs % 1_000) / 100);
return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}.${tenths}`;
}