feat(canvas): 新增视频节点字幕体系与多轨时间线编辑(关联 #187) (#191)
* fix(ci): 后端测试 - 修复 responses 流式测试数据与 Grok 生图测试 SSRF 放行 * feat(canvas): 新增视频节点字幕体系与多轨时间线编辑(关联 #187) --------- Co-authored-by: zhaoyue <zhaoyue@rongshantech.com>
This commit is contained in:
@@ -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)",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
67
web/src/lib/timeline/srt-parser.ts
Normal file
67
web/src/lib/timeline/srt-parser.ts
Normal 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"
|
||||
);
|
||||
}
|
||||
131
web/src/lib/timeline/srt-resegment.ts
Normal file
131
web/src/lib/timeline/srt-resegment.ts
Normal 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 }));
|
||||
}
|
||||
53
web/src/lib/timeline/subtitle-highlight-ai.ts
Normal file
53
web/src/lib/timeline/subtitle-highlight-ai.ts
Normal 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")}`;
|
||||
}
|
||||
113
web/src/lib/timeline/subtitle-highlight-runner.ts
Normal file
113
web/src/lib/timeline/subtitle-highlight-runner.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
44
web/src/lib/timeline/subtitle-highlight-service.ts
Normal file
44
web/src/lib/timeline/subtitle-highlight-service.ts
Normal 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);
|
||||
}
|
||||
91
web/src/lib/timeline/subtitle-highlights.ts
Normal file
91
web/src/lib/timeline/subtitle-highlights.ts
Normal 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;
|
||||
}
|
||||
184
web/src/lib/timeline/timeline-build.ts
Normal file
184
web/src/lib/timeline/timeline-build.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
93
web/src/lib/timeline/timeline-export.ts
Normal file
93
web/src/lib/timeline/timeline-export.ts
Normal 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)));
|
||||
}
|
||||
}
|
||||
196
web/src/lib/timeline/timeline-placement.ts
Normal file
196
web/src/lib/timeline/timeline-placement.ts
Normal 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);
|
||||
}
|
||||
61
web/src/lib/timeline/timeline-snap.ts
Normal file
61
web/src/lib/timeline/timeline-snap.ts
Normal 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 };
|
||||
}
|
||||
204
web/src/lib/timeline/timeline-to-ffmpeg.ts
Normal file
204
web/src/lib/timeline/timeline-to-ffmpeg.ts
Normal 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");
|
||||
}
|
||||
54
web/src/lib/timeline/timeline-tracks.ts
Normal file
54
web/src/lib/timeline/timeline-tracks.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
67
web/src/lib/timeline/timeline-view.ts
Normal file
67
web/src/lib/timeline/timeline-view.ts
Normal 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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user