You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.26 02:47:05