Co-authored-by: dlh <2773843782@qq.com> Original-PR: https://github.com/ddcat-ai/open-ai-canvas/pull/224
99 lines
4.7 KiB
TypeScript
99 lines
4.7 KiB
TypeScript
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)));
|
||
}
|
||
}
|