m3u8tomp4

  • 下载m3u8分片
  • 合并分片
  • 使用 ffmpeg 转为 mp4 文件

技术使用: Bun + ffmpeg

  • 使用 fetch 请求 .m3u8 分片文件
  • 使用 Bun.write 保存每个分片
  • 使用 Bun.file().writer 创建增量写入 writer: FileSink
  • 循环读取 Bun.file 每个分片,并通过 writer.write
  • 最后,通过 ffmpeg -i output.ts -c copy output.mp4 把合并完成的视频转换为mp4
import { file, write, readableStreamToArrayBuffer } from 'bun';
import { readdir, unlink  } from 'node:fs/promises';

const BASE_URL = '';

// 追加写入
// async function test() {
//   const dirs = await getDownloadParts();
//   const outputFile = file('./test/text_copy.txt');
//   const writer = outputFile.writer();
//   for (const dir of dirs) {
//     writer.write(`${dir}\r\n`);
//     await writer.flush();
//   }
//   console.log('写入完成');
//   await writer.end();
//   writer.unref();
// }

// 追加写入
// const outputFile = file('./test/text_copy.txt');
// const writer = outputFile.writer({ highWaterMark: 1024 });
// for (let i = 0; i < 1024 * 1024; i++) {
//   writer.write(`${i}\r\n`);
//   // await writer.flush()
// }
// console.log('写入完成');
// const text = await outputFile.text();
// console.log('text => ', text.split('\r\n').length);
// // await writer.end();

// 覆盖写入
// for (let i = 0; i < 100; i++) {
//   await write(file('./test/text_copy.txt'), i);
//   await Bun.sleep(500);
// }

run();
async function run() {
  await fetchM3u8(`${BASE_URL}/20240325/ihRFBhF0/1500kb/hls/index.m3u8`);
  await donwloadM3u8Part();
  await mergeParts();
  m3u82mp4();
}

async function fetchM3u8(url) {
  if (!url?.endsWith('.m3u8')) throw new Error('url is not a m3u8 file');
  const text = await fetch(url).then((res) => res.text());
  const matchs = text.match(/(.*\.ts)/g);
  await write(file('./test/text.txt'), matchs.join('\r\n'));
  console.log('m3u8 解析完成');
}

async function donwloadM3u8Part() {
  const bunFile = file('./test/text.txt');
  const text = await bunFile.text();
  const texts = text.split('\n');
  const tsList = Array.from({ length: texts.length }, (_, i) => ({ index: i + 1, src: texts[i] }));

  const dirs = await getDownloadParts();
  const len = dirs.length;

  for (const { src, index } of tsList) {
    // 已下载
    if (index > len - 1) {
      const videoBuf = await fetch(`${BASE_URL}${src}`).then((res) => res.arrayBuffer());
      await write(file(`./video/${index}.ts`), videoBuf);
    }

    const percent = parseFloat(((index / tsList.length) * 100).toFixed(2));
    console.clear();
    console.log('下载进度 => ', percent + '%');
  }

  console.log('m3u8 所有分片下载完成');
}

// 合并输出到一个.ts文件
async function mergeParts() {
  // output
  const dirs = await getDownloadParts();
  // console.log('dirs => ', dirs);

  const outputFile = file('./output/output.mp4');
  const writer = outputFile.writer();
  let count = 0;
  for (const dir of dirs) {
    const bunFile = file(`./video/${dir}`);
    const buf = await readableStreamToArrayBuffer(bunFile.stream());
    writer.write(buf);
    writer.flush();
    count++;
    console.log('合并进度 => ', parseFloat(((count / dirs.length) * 100).toFixed(2)) + '%');
  }
  console.log('增量写入完成');
  writer.end();
  writer.unref();
}

// 获取已下载的分片
async function getDownloadParts() {
  let dirs = await readdir('./video');
  dirs = dirs.filter((dir) => dir.endsWith('.ts'));

  dirs.sort((a, b) => {
    a = a.split('.')[0].padStart('0', 8);
    b = b.split('.')[0].padStart('0', 8);
    return a - b;
  });

  return dirs;
}

async function m3u82mp4() {
  // ffmpeg -i output.ts -c:v libx265 -crf 0 -r 60 copy output.mp4
  await Bun.$`ffmpeg -i output.ts  -c copy output.mp4`;
}

也可以不合并成一个文件,使用 Http Range,前端使用 MediaSource 添加分片播放

posted @ 2024-04-16 10:54  _clai  阅读(132)  评论(0编辑  收藏  举报