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

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

---------

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

94 lines
4.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 第三期:时间线导出运行时。把 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)));
}
}