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
添加分片播放