UNPKG

coze-plugin-utils

Version:

Comprehensive utility library for Coze plugins with multimedia processing, browser automation, cloud storage integration, and AI-powered video/audio generation capabilities

397 lines (348 loc) 11.8 kB
import ffmpeg from 'fluent-ffmpeg'; import ffmpegPath from 'ffmpeg-static'; import { promises as fs, existsSync } from 'node:fs'; import path from 'node:path'; import { detectMimeType } from './utils'; import { downloadFile, downloadFiles } from '../core'; if(ffmpegPath && existsSync(ffmpegPath)) { ffmpeg.setFfmpegPath(ffmpegPath as string); } /** * 将音频文件从一种格式转换为另一种格式 * * @param url - 输入音频文件网址 * @param desType - 目标音频格式 (例如: 'mp3', 'wav', 'ogg', 'flac') * @param srcType - 源音频格式 (可选,如果未提供将通过检测文件头来确定) * @returns Promise<string> - 转换后的文件路径 * * @example * // 将 input.wav 转换为 MP3 格式 * const outputPath = await convertAudio('https://site/to/path/sound.wav', 'mp3'); * */ export async function convertAudio( url: string, desType: string, srcType?: string, ): Promise<string> { const inputFile = await downloadFile(url, 'input.wav'); try { // 如果未提供源类型,则尝试检测 if (!srcType) { const buffer = await fs.readFile(inputFile.file); const mimeType = detectMimeType(buffer); if (!mimeType || !mimeType.startsWith('audio/')) { throw new Error(`无法检测到音频类型或文件不是音频文件: ${inputFile}`); } // 从 MIME 类型中提取格式 (例如 'audio/mpeg' -> 'mp3') srcType = mimeType.split('/')[1]; // 特殊情况处理 if (srcType === 'mpeg') srcType = 'mp3'; } // 规范化目标类型 desType = desType.toLowerCase().replace(/^\./,''); // 创建输出文件路径 const outputFile = inputFile.createOutput(`output.${desType}`); // 执行转换 await new Promise<string>((resolve, reject) => { ffmpeg(inputFile.file) .audioCodec(getAudioCodec(desType)) .format(desType) .on('error', (err) => { reject(new Error(`音频转换失败: ${err.message}`)); }) .on('end', () => { resolve(outputFile); }) .save(outputFile); }); return outputFile; } catch (error) { throw new Error(`音频转换失败: ${error}`); } } /** * 根据目标格式获取适当的音频编解码器 * * @param format - 目标音频格式 * @returns 适合该格式的编解码器 */ function getAudioCodec(format: string): string { switch (format.toLowerCase()) { case 'mp3': return 'libmp3lame'; case 'aac': return 'aac'; case 'ogg': case 'oga': return 'libvorbis'; case 'opus': return 'libopus'; case 'flac': return 'flac'; case 'wav': return 'pcm_s16le'; default: return 'copy'; // 默认尝试复制编解码器 } } export async function mergeVideoAndAudio( videoUrl: string, audioUrl: string, audioType?: 'wav' | 'mp3' | 'ogg' | 'm4a' | 'aac', ): Promise<string> { const audioExt = `.${audioType}` || path.extname(audioUrl) || '.wav'; // 下载视频和音频 const [videoFile, audioFile] = await downloadFiles([ { url: videoUrl, filename: 'video.mp4' }, { url: audioUrl, filename: `audio${audioExt}` }, ]); // console.log('downloaded'); let mp3File = audioFile.file; // 如果不是 .mp3,则转为 mp3 if (audioExt !== '.mp3') { mp3File = audioFile.createOutput('coverted.mp3'); await new Promise((resolve, reject) => { ffmpeg(audioFile.file) .audioCodec('libmp3lame') .format('mp3') .save(mp3File) .on('end', resolve) .on('error', reject); }); } // console.log(mp3File); const outputFile = videoFile.createOutput('output.mp4'); // 合成视频+音频 await new Promise((resolve, reject) => { ffmpeg() .input(videoFile.file) .input(mp3File) .outputOptions(['-c:v copy', '-c:a aac', '-shortest']) .save(outputFile) .on('end', resolve) .on('error', reject); }); return outputFile; } interface IAssEvents { text: string; effect?: string; layer?: number; start?: string; end?: string; style?: string; name?: string; marginL?: number; marginR?: number; marginV?: number; } /** * 生成带样式的 .ass 字幕文件 */ function generateASS(contents: IAssEvents[]): string { const assContent = ` [Script Info] ScriptType: v4.00+ PlayResX: 1920 PlayResY: 1080 Title: Auto Subtitle WrapStyle: 0 [V4+ Styles] Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding Style: Default,Source Han Sans CN,60,&H00FFFFFF,&H000000FF,&H64000000,&H64000000,-1,0,0,0,100,100,0,0,1,2,0,5,30,30,30,1 [Events] Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text ${contents.map((c) => `Dialogue: ${c.layer || 0},${c.start || '0:00:00.00'},${c.end || '0:00:10.00'},${c.style || 'Default'},${c.name || ''},${c.marginL || 0},${c.marginR || 0},${c.marginV || 0},,${c.effect||''}${c.text}`).join('\n')} `; return assContent; } /** * 合成字幕到视频(ASS方式 + 样式) */ export async function burnASSSubtitleToVideo( videoUrl: string, contents: IAssEvents[], ): Promise<string> { const videoFile = await downloadFile(videoUrl, 'input.mp4'); const assFile = videoFile.createOutput('temp_subtitle.ass'); const assText = generateASS(contents); await fs.writeFile(assFile, assText, 'utf-8'); const outputFile = videoFile.createOutput('output.mp4'); const fontsdir = path.resolve(__dirname, '..', '..', 'fonts'); await new Promise((resolve, reject) => { ffmpeg(videoFile.file) .videoFilters([{ filter: 'ass', options: { filename: assFile, fontsdir, }, }]) .on('start', (commandLine) => { console.log('[FFmpeg] 开始执行命令:', commandLine); }) .on('error', (err) => { console.error('[FFmpeg] 出错了:', err); reject(err); }) .on('end', () => { console.log('[FFmpeg] ✅ 字幕合成完成'); resolve(true); }) .save(outputFile); }); return outputFile; } /** * 将多个视频文件按顺序合并成一个视频 * * @param urls - 视频文件URL数组,按照需要合并的顺序排列 * @param outputFormat - 输出视频格式,默认为'mp4' * @returns Promise<string> - 合并后的视频文件路径 * * @example * // 合并三个视频文件 * const outputPath = await joinVideos([ * 'https://example.com/video1.mp4', * 'https://example.com/video2.mp4', * 'https://example.com/video3.mp4' * ]); */ export async function joinVideos( urls: string[], outputFormat: string = 'mp4', ): Promise<string> { if (!urls || urls.length === 0) { throw new Error('视频URL列表不能为空'); } if (urls.length === 1) { // 如果只有一个视频,直接下载并返回 const videoFile = await downloadFile(urls[0], 'single_video.mp4'); return videoFile.file; } // 规范化输出格式 outputFormat = outputFormat.toLowerCase().replace(/^\./, ''); // 下载所有视频文件 const videoFiles = await downloadFiles( urls.map((url, index) => ({ url, filename: `video_part_${index + 1}.mp4`, })), ); // 创建一个临时文件,用于存储视频文件列表 const listFile = videoFiles[0].createOutput('video_list.txt'); // 创建文件列表内容 const fileListContent = videoFiles .map((file) => `file '${file.file}'`) .join('\n'); // 写入文件列表 await fs.writeFile(listFile, fileListContent, 'utf-8'); // 创建输出文件路径 const outputFile = videoFiles[0].createOutput(`joined_video.${outputFormat}`); // 使用 FFmpeg 的 concat 分离器合并视频 await new Promise<void>((resolve, reject) => { ffmpeg() .input(listFile) .inputOptions(['-f', 'concat', '-safe', '0']) .outputOptions(['-c', 'copy']) // 使用复制模式,不重新编码以保持质量和速度 .on('start', (commandLine) => { console.log('[FFmpeg] 开始执行视频合并命令:', commandLine); }) .on('progress', (progress) => { if (progress.percent) { console.log(`[FFmpeg] 合并进度: ${Math.floor(progress.percent)}%`); } }) .on('error', (err) => { console.error('[FFmpeg] 视频合并出错:', err); reject(new Error(`视频合并失败: ${err.message}`)); }) .on('end', () => { console.log('[FFmpeg] ✅ 视频合并完成'); resolve(); }) .save(outputFile); }); return outputFile; } async function getDuration(filePath: string): Promise<number> { return new Promise((resolve, reject) => { ffmpeg.ffprobe(filePath, (err, metadata) => { if (err) return reject(err); const duration = metadata.format.duration; resolve(duration || 0); }); }); } function replaceChineseWithFontTag(text: string): string { // 匹配:中文、全角标点,中文间可以有空格也可以没有 const pattern = /([\u4e00-\u9fff\u3000-\u303F\uff00-\uffef](?:\s*[\u4e00-\u9fff\u3000-\u303F\uff00-\uffef])*)/g; return text.replace(pattern, (match) => { return `{\\fnSource Han Sans CN}${match}{\\fnOpen Sans}`; }); } export async function mergeWithDelayAndStretch( videoUrl: string, audioUrl: string, videoDuration?: number, audioDuration?: number, subtitle?: string, ): Promise<string> { // 下载视频和音频 const [videoPath, audioPath] = await downloadFiles([ { url: videoUrl, filename: 'video.mp4' }, { url: audioUrl, filename: `audio.mp3` }, ]); videoDuration = videoDuration || await getDuration(videoPath.file); audioDuration = audioDuration || await getDuration(audioPath.file); const rate = (0.5 + audioDuration) / videoDuration; const delayMs = 500; const videoFilter = rate > 1 ? `[0:v]setpts=${rate}*PTS[v]` : `[0:v]copy[v]`; // 检查是否需要补齐音频尾帧 const needPadding = videoDuration > (audioDuration + 0.5); const audioFilter = needPadding ? `[1:a]adelay=${delayMs}|${delayMs},apad=whole_dur=${videoDuration}[aud]` : `[1:a]adelay=${delayMs}|${delayMs}[aud]`; let filterComplex = `${videoFilter};${audioFilter}`; const outputPath = videoPath.createOutput('output.mp4'); if(subtitle) { const assFile = videoPath.createOutput('temp_subtitle.ass'); const assText = generateASS([ { text: replaceChineseWithFontTag(subtitle), effect: '{\\an2\\fnOpen Sans}', start: '0:00:00.50', end: '0:00:20.00', marginV: 100, marginL: 60, marginR: 60, }, ]); await fs.writeFile(assFile, assText, 'utf-8'); const fontsdir = path.resolve(__dirname, '..', '..', 'fonts'); filterComplex = `${filterComplex};[v]ass=${assFile}:fontsdir=${fontsdir}[vout]`; } await new Promise<void>((resolve, reject) => { ffmpeg() .input(videoPath.file) .input(audioPath.file) .complexFilter(filterComplex) .outputOptions(subtitle ? ['-map [vout]', '-map [aud]', '-c:v libx264', '-c:a aac'] : ['-map [v]', '-map [aud]', '-c:v libx264', '-c:a aac']) .on('start', (commandLine) => { console.log('[FFmpeg] 开始执行命令:', commandLine); }) .on('end', () => { console.log('✅ 合成完成'); resolve(); }) .on('error', (err) => { console.error('❌ 出错了:', err.message); reject(new Error(`视频合并失败: ${err.message}`)); }) .save(outputPath); }); return outputPath; }