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

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

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

---------

Co-authored-by: zhaoyue <zhaoyue@rongshantech.com>
This commit is contained in:
爱笑的毛毛虫andzhaoyue authored and GitHub committed 2026-08-08 18:28:28 +08:00
1 parent b26b641ec3
commit 62b9c420dd
33 files changed
+3724 -30

No files matched your search

+67
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}`;
}