Files
ttstd-infinite-canvas/web/src/lib/canvas/canvas-video-segment.ts

99 lines
4.7 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.
import { fetchFile } from "@ffmpeg/util";
import { getMediaBlob } from "@/services/file-storage";
import { buildExtractAudioArgs, buildSegmentTrimArgs, SEGMENT_INPUT_NAME, SEGMENT_OUTPUT_NAME } from "./canvas-video-segment-args";
import { loadFFmpeg } from "./canvas-video-merge";
export type VideoSegmentRange = {
startMs: number;
endMs: number;
};
export type VideoSegmentSource = {
url?: string;
storageKey?: string;
};
export type VideoSegmentProgress = {
phase: "loading" | "reading" | "encoding";
progress: number;
};
const INPUT_NAME = SEGMENT_INPUT_NAME;
const OUTPUT_NAME = SEGMENT_OUTPUT_NAME;
function assertValidRange(range: VideoSegmentRange, durationMs?: number) {
const startMs = Math.max(0, Math.round(range.startMs));
const endMs = Math.round(range.endMs);
if (endMs <= startMs) throw new Error("片段结束时间必须晚于开始时间");
if (durationMs !== undefined && endMs > Math.round(durationMs)) throw new Error("片段结束时间超过视频时长");
}
async function readVideoSourceBlob(source: VideoSegmentSource) {
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("找不到视频素材,请重新上传后再操作");
}
async function runSegmentJob(
source: VideoSegmentSource,
range: VideoSegmentRange,
durationMs: number | undefined,
buildArgs: (startSec: string, durationSec: string) => string[],
onProgress?: (progress: VideoSegmentProgress) => void,
outputType = "video/mp4",
) {
assertValidRange(range, durationMs);
const ffmpeg = await loadFFmpeg(({ phase, progress }) => onProgress?.({ phase: phase === "loading" ? "loading" : "reading", progress }));
const blob = await readVideoSourceBlob(source);
onProgress?.({ phase: "reading", progress: 45 });
await ffmpeg.writeFile(INPUT_NAME, await fetchFile(blob));
const startSec = String(range.startMs / 1000);
const durationSec = String((range.endMs - range.startMs) / 1000);
onProgress?.({ phase: "encoding", progress: 55 });
try {
const exitCode = await ffmpeg.exec(["-y", ...buildArgs(startSec, durationSec)]);
if (exitCode !== 0) throw new Error("媒体处理失败,请确认视频编码格式兼容");
const output = await ffmpeg.readFile(OUTPUT_NAME);
onProgress?.({ phase: "encoding", progress: 100 });
return new Blob([output as BlobPart], { type: outputType });
} finally {
await Promise.all([INPUT_NAME, OUTPUT_NAME].map((file) => ffmpeg.deleteFile(file).catch(() => undefined)));
}
}
/** 按片段范围截取视频,输出统一编码 MP4(复用时间线 trim 的参数模板)。 */
export async function trimVideoSegment(source: VideoSegmentSource, range: VideoSegmentRange, durationMs?: number, onProgress?: (progress: VideoSegmentProgress) => void) {
return runSegmentJob(source, range, durationMs, (startSec, durationSec) => buildSegmentTrimArgs(startSec, durationSec), onProgress, "video/mp4");
}
/** 从视频片段提取声音为 MP3;优先 libmp3lame,内核不支持时回退默认 mp3 编码器。 */
export async function extractVideoAudio(source: VideoSegmentSource, range: VideoSegmentRange, durationMs?: number, onProgress?: (progress: VideoSegmentProgress) => void) {
const ffmpeg = await loadFFmpeg(({ phase, progress }) => onProgress?.({ phase: phase === "loading" ? "loading" : "reading", progress }));
const blob = await readVideoSourceBlob(source);
onProgress?.({ phase: "reading", progress: 45 });
await ffmpeg.writeFile(INPUT_NAME, await fetchFile(blob));
assertValidRange(range, durationMs);
const startSec = String(range.startMs / 1000);
const durationSec = String((range.endMs - range.startMs) / 1000);
onProgress?.({ phase: "encoding", progress: 55 });
try {
const args = (audioCodec: string) => buildExtractAudioArgs(audioCodec, startSec, durationSec);
let exitCode = await ffmpeg.exec(["-y", ...args("libmp3lame")]);
if (exitCode !== 0) exitCode = await ffmpeg.exec(["-y", ...args("mp3")]);
if (exitCode !== 0) throw new Error("音频提取失败:当前 FFmpeg 内核不支持 MP3 编码");
const output = await ffmpeg.readFile(OUTPUT_NAME);
onProgress?.({ phase: "encoding", progress: 100 });
return new Blob([output as BlobPart], { type: "audio/mpeg" });
} finally {
await Promise.all([INPUT_NAME, OUTPUT_NAME].map((file) => ffmpeg.deleteFile(file).catch(() => undefined)));
}
}