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

783 lines (781 loc) 33.7 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.convertAudio = convertAudio; exports.mergeVideoAndAudio = mergeVideoAndAudio; exports.burnASSSubtitleToVideo = burnASSSubtitleToVideo; exports.joinVideos = joinVideos; exports.mergeWithNarrationAudios = mergeWithNarrationAudios; exports.mergeWithDelayAndStretch = mergeWithDelayAndStretch; exports.createKenBurnsVideoFromImages = createKenBurnsVideoFromImages; exports.generateAssSubtitleForSong = generateAssSubtitleForSong; const fluent_ffmpeg_1 = __importDefault(require("fluent-ffmpeg")); const ffmpeg_static_1 = __importDefault(require("ffmpeg-static")); const node_fs_1 = __importStar(require("node:fs")); const node_path_1 = __importDefault(require("node:path")); const image_size_1 = __importDefault(require("image-size")); const core_1 = require("../core"); const utils_1 = require("./utils"); if (ffmpeg_static_1.default && (0, node_fs_1.existsSync)(ffmpeg_static_1.default)) { fluent_ffmpeg_1.default.setFfmpegPath(ffmpeg_static_1.default); } /** * 将音频文件从一种格式转换为另一种格式 * * @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'); * */ async function convertAudio(url, desType, srcType) { const inputFile = await (0, core_1.downloadFile)(url, 'input.wav'); try { // 如果未提供源类型,则尝试检测 if (!srcType) { const buffer = await node_fs_1.promises.readFile(inputFile.file); const mimeType = (0, utils_1.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((resolve, reject) => { (0, fluent_ffmpeg_1.default)(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) { 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'; // 默认尝试复制编解码器 } } async function mergeVideoAndAudio(videoUrl, audioUrl, audioType) { const audioExt = `.${audioType}` || node_path_1.default.extname(audioUrl) || '.wav'; // 下载视频和音频 const [videoFile, audioFile] = await (0, core_1.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) => { (0, fluent_ffmpeg_1.default)(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) => { (0, fluent_ffmpeg_1.default)() .input(videoFile.file) .input(mp3File) .outputOptions(['-c:v copy', '-c:a aac', '-shortest']) .save(outputFile) .on('end', resolve) .on('error', reject); }); return outputFile; } /** * 生成带样式的 .ass 字幕文件 */ function generateASS(contents) { 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方式 + 样式) */ async function burnASSSubtitleToVideo(videoUrl, contents) { const videoFile = await (0, core_1.downloadFile)(videoUrl, 'input.mp4'); const assFile = videoFile.createOutput('temp_subtitle.ass'); const assText = generateASS(contents); await node_fs_1.promises.writeFile(assFile, assText, 'utf-8'); const outputFile = videoFile.createOutput('output.mp4'); const fontsdir = node_path_1.default.resolve(__dirname, '..', '..', 'fonts'); await new Promise((resolve, reject) => { (0, fluent_ffmpeg_1.default)(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' * ]); */ async function joinVideos(urls, outputFormat = 'mp4') { if (!urls || urls.length === 0) { throw new Error('视频URL列表不能为空'); } if (urls.length === 1) { // 如果只有一个视频,直接下载并返回 const videoFile = await (0, core_1.downloadFile)(urls[0], 'single_video.mp4'); return videoFile.file; } // 规范化输出格式 outputFormat = outputFormat.toLowerCase().replace(/^\./, ''); // 下载所有视频文件 const videoFiles = await (0, core_1.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 node_fs_1.promises.writeFile(listFile, fileListContent, 'utf-8'); // 创建输出文件路径 const outputFile = videoFiles[0].createOutput(`joined_video.${outputFormat}`); // 使用 FFmpeg 的 concat 分离器合并视频 await new Promise((resolve, reject) => { (0, fluent_ffmpeg_1.default)() .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) { return new Promise((resolve, reject) => { fluent_ffmpeg_1.default.ffprobe(filePath, (err, metadata) => { if (err) return reject(err); const duration = metadata.format.duration; resolve(duration || 0); }); }); } function replaceChineseWithFontTag(text) { // 匹配:中文、全角标点,中文间可以有空格也可以没有 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}`; }); } async function mergeWithNarrationAudios(videoDuration, audioDuration, videoUrl, narrations, audioDelayMs = 500) { // 下载视频和所有音频文件 const downloadTasks = [ { url: videoUrl, filename: 'video.mp4' }, ...narrations.map((n, i) => ({ url: n.url, filename: `narration_${i}.mp3` })), ]; const files = await (0, core_1.downloadFiles)(downloadTasks); const [videoFile, ...narrationFiles] = files; // 计算视频速率 const rate = (audioDuration + audioDelayMs / 1000) / videoDuration; const videoFilter = rate > 1 ? `[0:v]setpts=${rate}*PTS[v]` : `[0:v]copy[v]`; // 生成字幕文件 const assFile = videoFile.createOutput('temp_subtitle.ass'); const assEvents = narrations.map((narration, index) => { const startTime = index === 0 ? audioDelayMs / 1000 : narrations .slice(0, index) .reduce((acc, curr) => acc + curr.duration, audioDelayMs / 1000); return { text: replaceChineseWithFontTag(narration.text), effect: '{\\an2\\fnOpen Sans}', start: formatTime(startTime), end: formatTime(startTime + narration.duration), marginV: 100, marginL: 60, marginR: 60, }; }); const assText = generateASS(assEvents); await node_fs_1.promises.writeFile(assFile, assText, 'utf-8'); // 构建音频混合滤镜 let delayMs = audioDelayMs; const audioFilters = narrationFiles.map((_, i) => { const ret = `[${i + 1}:a]adelay=${Math.round(delayMs)}|${Math.round(delayMs)}[a${i}]`; delayMs += narrations[i].duration * 1000; return ret; }); const audioMixInputs = narrationFiles.map((_, i) => `[a${i}]`).join(''); let audioFilter = `${audioFilters.join(';')};${audioMixInputs}amix=inputs=${narrationFiles.length}[aud]`; const needPadding = rate < 1; if (needPadding) { audioFilter = audioFilter.replace(/\[aud\]$/, `:duration=longest,apad=whole_dur=${videoDuration}[aud]`); } // console.log(rate, assText, audioFilter); // 添加字幕 const fontsdir = node_path_1.default.resolve(__dirname, '..', '..', 'fonts'); const filterComplex = `${videoFilter};${audioFilter};[v]ass=${assFile}:fontsdir=${fontsdir}[vout]`; const outputPath = videoFile.createOutput('output.mp4'); await new Promise((resolve, reject) => { const command = (0, fluent_ffmpeg_1.default)(); // 添加视频输入 command.input(videoFile.file); // 添加所有旁白音频输入 narrationFiles.forEach((file) => { command.input(file.file); }); command .complexFilter(filterComplex) .outputOptions([ '-map [vout]', '-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; } async function mergeWithDelayAndStretch(videoUrl, audioUrl, videoDuration, audioDuration, subtitle, audioDelayMs = 500) { // 下载视频和音频 const [videoPath, audioPath] = await (0, core_1.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 videoFilter = rate > 1 ? `[0:v]setpts=${rate}*PTS[v]` : `[0:v]copy[v]`; // 检查是否需要补齐音频尾帧 const needPadding = videoDuration > (audioDuration + audioDelayMs / 1000); // 构建音频滤镜,如果 delayMs 为 0 则跳过延迟 const audioFilter = needPadding ? `[1:a]adelay=${audioDelayMs}|${audioDelayMs},apad=whole_dur=${videoDuration}[aud]` : `[1:a]adelay=${audioDelayMs}|${audioDelayMs}[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 node_fs_1.promises.writeFile(assFile, assText, 'utf-8'); const fontsdir = node_path_1.default.resolve(__dirname, '..', '..', 'fonts'); filterComplex = `${filterComplex};[v]ass=${assFile}:fontsdir=${fontsdir}[vout]`; } await new Promise((resolve, reject) => { (0, fluent_ffmpeg_1.default)() .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; } /** * 生成简单的 Ken Burns 运动参数 * @param index 图片索引,用于交替缩放方向 * @param enableShake 是否启用抖动效果 * @param shakeIntensity 抖动强度 * @returns 运动参数对象 */ function generateKenBurnsMotion(index, enableShake = false, shakeIntensity = 0.02) { // 简单的交替模式:奇数图片放大,偶数图片缩小 const isZoomIn = index % 2 === 0; let startX = 0; let startY = 0; let endX = 0; let endY = 0; // 如果启用抖动,添加轻微的随机偏移 if (enableShake) { // 使用图片索引作为种子,确保每次生成相同的"随机"值 const seed = index * 12345; const random1 = ((seed * 9301 + 49297) % 233280) / 233280; const random2 = (((seed + 1) * 9301 + 49297) % 233280) / 233280; const random3 = (((seed + 2) * 9301 + 49297) % 233280) / 233280; const random4 = (((seed + 3) * 9301 + 49297) % 233280) / 233280; // 减小抖动范围,使用更小的偏移量 startX = 0.5 + (random1 - 0.5) * shakeIntensity; startY = 0.5 + (random2 - 0.5) * shakeIntensity; endX = 0.5 + (random3 - 0.5) * shakeIntensity; endY = 0.5 + (random4 - 0.5) * shakeIntensity; // 确保坐标在合理范围内 startX = Math.max(0.1, Math.min(0.9, startX)); startY = Math.max(0.1, Math.min(0.9, startY)); endX = Math.max(0.1, Math.min(0.9, endX)); endY = Math.max(0.1, Math.min(0.9, endY)); } return { startZoom: isZoomIn ? 1.0 : 1.5, endZoom: isZoomIn ? 1.5 : 1.0, startX, startY, endX, endY, }; } /** * 将秒数转换为ASS时间格式 (H:MM:SS.CC) */ function formatTime(seconds) { const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); const secs = Math.floor(seconds % 60); const centiseconds = Math.floor((seconds % 1) * 100); return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}.${centiseconds.toString().padStart(2, '0')}`; } async function createKenBurnsVideoFromImages({ scenes, resolution, fadeDuration = 0.2, fps = 25, enableShake = false, shakeIntensity = 0.02, subtitles, }) { return new Promise(async (resolve, reject) => { const inputs = (0, fluent_ffmpeg_1.default)(); const filters = []; // 下载所有图片 const imageFiles = await (0, core_1.downloadFiles)(scenes.map(({ url }, index) => ({ url, filename: `video_scene_${index + 1}.png`, }))); if (!imageFiles.length) { throw new Error('图片下载失败'); } // 获取第一张图片的分辨率 if (!resolution) { const firstImagePath = imageFiles[0].file; try { const imageBuffer = node_fs_1.default.readFileSync(firstImagePath); const dimensions = (0, image_size_1.default)(imageBuffer); if (dimensions.width && dimensions.height) { // 确保分辨率为偶数,FFmpeg 要求 const width = dimensions.width % 2 === 0 ? dimensions.width : dimensions.width - 1; const height = dimensions.height % 2 === 0 ? dimensions.height : dimensions.height - 1; resolution = `${width}x${height}`; } else { throw new Error('无法获取图片分辨率'); } } catch (error) { throw new Error(`获取图片分辨率失败: ${error}`); } } console.log('🎬 检测到图片分辨率:', resolution); const outputPath = imageFiles[0].createOutput('output.mp4'); // 收集所有字幕信息,用于生成ASS文件 const assEvents = []; // 收集所有音频信息 let audioFiles = []; let currentTime = 0; const audios = scenes.map((scene, index) => ({ url: scene.audio || '', filename: `audio_${index}` })); if (audios[0].url) { const downloadedFiles = await (0, core_1.downloadFiles)(audios); audioFiles = downloadedFiles.map((item, index) => { const ret = { file: item.file, start: currentTime, delay: scenes[index].audioDelay || 0.5, }; currentTime += scenes[index].duration; return ret; }); } // 重置时间计数器 currentTime = 0; imageFiles.forEach((item, index) => { const scene = scenes[index]; const { duration, subtitle, subtitlePosition = 'bottom', subtitleDelay = 0, subtitleFontSize = 60 } = scene; const totalFrames = Math.floor(duration * fps); const fadeOutStartTime = Math.max(0, duration - fadeDuration); inputs.input(item.file); // 使用 zoompan 滤镜创建 Ken Burns 效果 const motion = generateKenBurnsMotion(index, enableShake, shakeIntensity); // 构建 zoompan 滤镜参数,使用更大的缩放幅度和平滑的线性插值 const zoomPanFilter = [ `zoompan=z='${motion.startZoom}+(${motion.endZoom}-${motion.startZoom})*(on-1)/(${totalFrames}-1)'`, `x='${motion.startX}*iw+(${motion.endX}-${motion.startX})*iw*(on-1)/(${totalFrames}-1)-iw/zoom/2'`, `y='${motion.startY}*ih+(${motion.endY}-${motion.startY})*ih*(on-1)/(${totalFrames}-1)-ih/zoom/2'`, `d=${totalFrames}`, `s=${resolution || '1280x720'}`, `fps=${fps}`, ].join(':'); // 构建滤镜链,如果 fadeDuration 为 0 则跳过淡出效果 let filterChain = `[${index}:v]${zoomPanFilter}`; if (fadeDuration > 0) { filterChain += `,fade=t=in:st=0:d=${fadeDuration},fade=t=out:st=${fadeOutStartTime}:d=${fadeDuration}`; } // 收集字幕信息 if (subtitle) { const subtitleStart = currentTime + subtitleDelay; const subtitleEnd = currentTime + duration - (fadeDuration > 0 ? fadeDuration : 0); // 根据位置设置对齐方式和边距 let alignment = 2; // 底部居中 let marginV = 100; if (subtitlePosition === 'top') { alignment = 8; // 顶部居中 marginV = 50; } else if (subtitlePosition === 'middle') { alignment = 5; // 中间居中 marginV = 0; } assEvents.push({ text: replaceChineseWithFontTag(subtitle), start: formatTime(subtitleStart), end: formatTime(subtitleEnd), effect: `{\\an${alignment}\\fs${subtitleFontSize}}`, marginV, marginL: 60, marginR: 60, }); } const label = `[v${index}]`; filters.push(filterChain + label); currentTime += duration; }); const concatInputs = scenes.map((_, i) => `[v${i}]`).join(''); let filterComplex = [ ...filters, `${concatInputs}concat=n=${scenes.length}:v=1:a=0[outv]`, ]; // 添加音频输入和处理 const audioInputCount = imageFiles.length; const audioFilters = []; if (audioFiles.length > 0) { // 为每个音频文件添加输入 audioFiles.forEach((audioInfo, index) => { inputs.input(audioInfo.file); const audioIndex = audioInputCount + index; const startTime = audioInfo.start + audioInfo.delay; // 为每个音频添加延迟滤镜 audioFilters.push(`[${audioIndex}:a]adelay=${Math.round(startTime * 1000)}|${Math.round(startTime * 1000)}[a${index}]`); }); // 如果有多个音频,需要混音 if (audioFiles.length > 1) { const audioInputs = audioFiles.map((_, i) => `[a${i}]`).join(''); audioFilters.push(`${audioInputs}amix=inputs=${audioFiles.length}:duration=longest[outa]`); } else { audioFilters.push(`[a0]anull[outa]`); } } // 如果有字幕,生成ASS文件并添加字幕滤镜 if (assEvents.length > 0 || subtitles) { let assFile; let assText; if (subtitles) { // 如果提供了卡拉OK字幕参数,生成卡拉OK字幕 assFile = imageFiles[0].createOutput('karaoke_subtitle.ass'); assText = generateAssSubtitleForSong(subtitles.title, subtitles.author, subtitles.sentences); // console.log(assText); } else { // 使用原有的字幕生成逻辑 assFile = imageFiles[0].createOutput('temp_subtitle.ass'); assText = generateASS(assEvents); } await node_fs_1.promises.writeFile(assFile, assText, 'utf-8'); const fontsdir = node_path_1.default.resolve(__dirname, '..', '..', 'fonts'); // 修改滤镜链,添加ASS字幕 filterComplex = [ ...filters, `${concatInputs}concat=n=${scenes.length}:v=1:a=0[v_concat]`, `[v_concat]ass=${assFile}:fontsdir=${fontsdir}[outv]`, ]; } // 添加音频滤镜到复合滤镜中 if (audioFilters.length > 0) { filterComplex = [...filterComplex, ...audioFilters]; } // 构建输出选项 const outputOptions = ['-pix_fmt yuv420p']; if (audioFiles.length > 0) { outputOptions.push('-c:a aac', '-b:a 128k'); inputs .complexFilter(filterComplex, ['outv', 'outa']) .outputOptions(outputOptions) .output(outputPath) .on('start', (command) => { console.log('[ffmpeg start]', command); }) .on('error', (err) => { console.error('[ffmpeg error]', err.message); const tmpDir = (0, core_1.getTempPath)(audioFiles[0].file); node_fs_1.default.rmSync(tmpDir, { recursive: true, force: true }); reject(err); }) .on('end', () => { console.log('✅ 视频生成完成:', outputPath); const tmpDir = (0, core_1.getTempPath)(audioFiles[0].file); node_fs_1.default.rmSync(tmpDir, { recursive: true, force: true }); resolve(outputPath); }) .run(); } else { inputs .complexFilter(filterComplex, 'outv') .outputOptions(outputOptions) .output(outputPath) .on('start', (command) => { console.log('[ffmpeg start]', command); }) .on('error', (err) => { console.error('[ffmpeg error]', err.message); reject(err); }) .on('end', () => { console.log('✅ 视频生成完成:', outputPath); resolve(outputPath); }) .run(); } }); } function generateAssSubtitleForSong(title, author, sentences) { // ASS 字幕文件头部 const header = [ '[Script Info]', `Title: ${title}`, `Original Script: ${author}`, 'ScriptType: v4.00+', 'Collisions: Normal', 'PlayResX: 1920', 'PlayResY: 1080', 'Timer: 100.0000', '', '[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,Arial,54,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1', 'Style: Translation,Arial,54,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,8,10,10,100,1', 'Style: KaraokeHighlight,Arial,54,&H0000FFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,8,10,10,150,1', '', '[Events]', 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', ].join('\n'); // 生成对话事件 const events = []; // 添加标题和作者信息 events.push(`Dialogue: 0,0:00:00.00,0:00:03.00,Default,,0,0,0,,{\\an5\\fnSource Han Sans CN\\fs80}${title} - ${author}`); // 处理每个部分的歌词 sentences.forEach((part, partIndex) => { // 处理整句歌词(显示在底部) if (part.text && part.words && part.words.length > 0) { const firstWord = part.words[0]; // 计算开始和结束时间 const startTime = part.startTime; const endTime = part.endTime; // 转换时间格式为 ASS 格式 (h:mm:ss.cc) const startTimeFormatted = formatAssTime(startTime); const endTimeFormatted = formatAssTime(endTime); // 处理逐字卡拉OK效果 // 为整句创建一个卡拉OK行,包含所有单词的时间信息 let karaokeText = '{\\an2\\fnSource Han Sans CN}'; const delay = Math.round((firstWord.start_time - startTime) / 10); if (delay > 0) karaokeText += `{\\k${delay}} `; const words = part.words; for (let i = 0; i < words.length; i++) { const word = words[i]; const nextWord = words[i + 1]; let nextStartTime = word.end_time; if (nextWord) nextStartTime = nextWord.start_time; // 计算每个词的持续时间(以厘秒为单位) const duration = Math.round((nextStartTime - word.start_time) / 10); karaokeText += `{\\k${duration}}${word.text}`; } // 添加卡拉OK效果行(显示在底部) events.push(`Dialogue: 0,${startTimeFormatted},${endTimeFormatted},KaraokeHighlight,,0,0,0,,${karaokeText}`); if (part.translation) { // 添加翻译行(显示在顶部) const translationText = `{\\an2\\fnOpen Sans}${part.translation}`; events.push(`Dialogue: 0,${startTimeFormatted},${endTimeFormatted},Translation,,0,0,0,,${translationText}`); } } }); // 合并头部和事件部分 const ret = `${header}\n${events.join('\n')}`; // console.log(ret); return ret; } /** * 将毫秒时间转换为 ASS 字幕格式的时间字符串 * @param ms 毫秒时间 * @returns 格式化的时间字符串 (h:mm:ss.cc) */ function formatAssTime(ms) { const totalSeconds = ms / 1000; const hours = Math.floor(totalSeconds / 3600); const minutes = Math.floor((totalSeconds % 3600) / 60); const seconds = Math.floor(totalSeconds % 60); const centiseconds = Math.floor((ms % 1000) / 10); return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${centiseconds.toString().padStart(2, '0')}`; } //# sourceMappingURL=ffmpeg.js.map