Next.js视频下载器仅得静音音视频,求FFmpeg合并及Socket.io进度修复方案
问题解决:音视频合并与进度显示修复
一、音视频未合并的核心问题及修复
1. 缺失path模块导入
代码中使用path.join但未导入path模块,会导致文件路径生成错误,无法正确创建/找到音视频文件,进而跳过合并步骤。需添加:
import path from 'path';
2. 未确保输出目录存在
如果mp4s目录不存在,fs.createWriteStream会执行失败,需在生成文件路径前递归创建目录:
const outputDir = path.join(process.cwd(), "mp4s"); fs.mkdirSync(outputDir, { recursive: true });
3. 错误的流完成监听逻辑
原代码在videoOutput的finish事件中才监听audioOutput的finish,若音频先下载完成,该监听永远不会触发,导致合并逻辑不执行。应使用Promise等待两个流都完成:
// 封装流的finish事件为Promise const waitStreamFinish = (stream) => { return new Promise((resolve, reject) => { stream.on("finish", resolve); stream.on("error", reject); }); }; // 等待视频和音频流都完成 await Promise.all([ waitStreamFinish(videoOutput), waitStreamFinish(audioOutput) ]);
4. FFmpeg参数优化
-strict -2是旧版本FFmpeg对AAC编码器的兼容参数,新版本已无需保留,可直接移除。
二、下载进度显示不正确的修复
1. 总字节数计算错误
原代码仅计算视频的字节数,需将视频和音频的总字节数相加:
const videoBytes = videoFormat.contentLength ? parseInt(videoFormat.contentLength) : videoInfo.length_seconds * 1000000; const audioBytes = audioFormat.contentLength ? parseInt(audioFormat.contentLength) : videoInfo.length_seconds * 1000000 * 0.1; // 估算音频大小 const totalBytes = videoBytes + audioBytes;
2. 仅监听视频流的下载进度
需同时监听视频流和音频流的data事件,累加所有下载的字节:
let downloadedBytes = 0; // 监听视频流下载进度 videoStream.on("data", (chunk) => { downloadedBytes += chunk.length; const progress = Math.min(Math.round((downloadedBytes / totalBytes) * 100), 100); sendProgress({ progress }); }); // 监听音频流下载进度 audioStream.on("data", (chunk) => { downloadedBytes += chunk.length; const progress = Math.min(Math.round((downloadedBytes / totalBytes) * 100), 100); sendProgress({ progress }); });
3. 补充FFmpeg合并进度(可选)
若需要展示合并阶段的进度,可监听FFmpeg的progress事件:
command.on("progress", (progress) => { // 合并阶段进度,下载完成后从100%开始,前端可展示为"处理中" const mergeProgress = 100 + Math.round(progress.percent / 2); sendProgress({ progress: Math.min(mergeProgress, 100) }); });
修改后的完整代码
import ytdl from "ytdl-core"; import fs from "fs"; import path from "path"; // 新增path导入 import { Server } from "socket.io"; import ffmpeg from "fluent-ffmpeg"; export default async function handler(req, res) { if (res.socket.server.io) { console.log("Socket is already running"); res.end(); return; } console.log("Socket is initializing"); const io = new Server(res.socket.server); res.socket.server.io = io; io.on("connection", async (socket) => { console.log(socket.id, "socketID"); const sendError = async (msg) => { socket.emit("showError", msg); }; const sendProgress = async (msg) => { console.log(msg); socket.emit("showProgress", msg); }; const sendComplete = async (msg) => { console.log(msg); socket.emit("showComplete", msg); }; // 封装流完成事件为Promise const waitStreamFinish = (stream) => { return new Promise((resolve, reject) => { stream.on("finish", resolve); stream.on("error", reject); }); }; const downloadVideo = async (url) => { try { const videoInfo = await ytdl.getInfo(url); const outputDir = path.join(process.cwd(), "mp4s"); // 确保输出目录存在 fs.mkdirSync(outputDir, { recursive: true }); // 处理标题特殊字符,避免路径错误 const sanitizedTitle = videoInfo.videoDetails.title.replace(/[<>:"/\\|?*]/g, "_"); const outputPath = path.join(outputDir, `${sanitizedTitle}.mp4`); const audioPath = path.join(outputDir, `${sanitizedTitle}.mp3`); const finalOutputPath = path.join(outputDir, `${sanitizedTitle}-with-audio.mp4`); const videoFormat = ytdl.chooseFormat(videoInfo.formats, { quality: "highestvideo", filter: "videoonly", }); const audioFormat = ytdl.chooseFormat(videoInfo.formats, { quality: "highestaudio", filter: "audioonly", }); const videoStream = ytdl(url, { quality: videoFormat.itag }); const audioStream = ytdl(url, { quality: audioFormat.itag }); const videoOutput = fs.createWriteStream(outputPath); const audioOutput = fs.createWriteStream(audioPath); audioStream.pipe(audioOutput); videoStream.pipe(videoOutput); // 计算总下载字节数 const videoBytes = videoFormat.contentLength ? parseInt(videoFormat.contentLength) : videoInfo.length_seconds * 1000000; const audioBytes = audioFormat.contentLength ? parseInt(audioFormat.contentLength) : videoInfo.length_seconds * 1000000 * 0.1; const totalBytes = videoBytes + audioBytes; let downloadedBytes = 0; // 监听视频流下载进度 videoStream.on("data", (chunk) => { downloadedBytes += chunk.length; const progress = Math.min(Math.round((downloadedBytes / totalBytes) * 100), 100); sendProgress({ progress }); }); // 监听音频流下载进度 audioStream.on("data", (chunk) => { downloadedBytes += chunk.length; const progress = Math.min(Math.round((downloadedBytes / totalBytes) * 100), 100); sendProgress({ progress }); }); // 处理流错误 videoStream.on("error", (err) => { console.error(err); sendError({ status: "error", message: "视频下载出错", }); }); audioStream.on("error", (err) => { console.error(err); sendError({ status: "error", message: "音频下载出错", }); }); // 等待音视频都下载完成 await Promise.all([ waitStreamFinish(videoOutput), waitStreamFinish(audioOutput) ]); // 检查文件是否存在 if (!fs.existsSync(outputPath) || !fs.existsSync(audioPath)) { console.error("音视频文件缺失"); sendError({ status: "error", message: "音视频文件缺失", }); return; } // FFmpeg合并音视频 const command = ffmpeg() .input(outputPath) .input(audioPath) .outputOptions("-c:v copy") // 直接复制视频流,无需重新编码 .outputOptions("-c:a aac") .outputOptions("-b:a 192k") .output(finalOutputPath) .on("progress", (progress) => { // 合并阶段进度,下载完成后从100%开始,前端可展示为"处理中" const mergeProgress = 100 + Math.round(progress.percent / 2); sendProgress({ progress: Math.min(mergeProgress, 100) }); }) .on("end", () => { // 删除临时文件 fs.unlink(outputPath, (err) => err && console.error("删除视频临时文件失败:", err)); fs.unlink(audioPath, (err) => err && console.error("删除音频临时文件失败:", err)); sendComplete({ status: "success", filePath: finalOutputPath }); }) .on("error", (err) => { console.error("FFmpeg合并错误:", err.message); sendError({ status: "error", message: "音视频合并出错", }); }); command.run(); } catch (error) { console.error(error); sendError({ status: "error", message: "请求处理出错", }); } }; socket.on("downloadVideo", downloadVideo); }); res.end(); }
额外注意事项
- 确保服务器已安装FFmpeg:
fluent-ffmpeg依赖系统中的FFmpeg可执行文件,需在服务器上安装FFmpeg(如Ubuntu用apt install ffmpeg,CentOS用yum install ffmpeg)。 - 文件名处理:视频标题可能包含特殊字符(如/、\等),代码中已添加转义处理,避免路径错误。
内容的提问来源于stack exchange,提问作者Stef-Lev
相关产品推荐
相关产品推荐

