veestream
Version:
A CLI tool to convert videos into multiple resolutions and generate HLS playlists for seamless adaptive streaming.
178 lines (142 loc) • 6.63 kB
text/typescript
import fs from 'fs';
import ora from 'ora';
import path from 'path';
import chalk from 'chalk';
import prompts from 'prompts';
import { program } from 'commander';
import { exec } from 'child_process';
console.log(
chalk.blueBright(`
██╗ ██╗███████╗███████╗███████╗████████╗██████╗ ███████╗ █████╗ ███╗ ███╗
██║ ██║██╔════╝██╔════╝██╔════╝╚══██╔══╝██╔══██╗██╔════╝██╔══██╗████╗ ████║
██║ ██║█████╗ █████╗ ███████╗ ██║ ██████╔╝█████╗ ███████║██╔████╔██║
╚██╗ ██╔╝██╔══╝ ██╔══╝ ╚════██║ ██║ ██╔══██╗██╔══╝ ██╔══██║██║╚██╔╝██║
╚████╔╝ ███████╗███████╗███████║ ██║ ██║ ██║███████╗██║ ██║██║ ╚═╝ ██║
╚═══╝ ╚══════╝╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝
`)
);
program
.description('A CLI tool to convert videos using FFmpeg')
.option('-i, --input <file>', 'input mp4 file')
.option('-o, --output <dir>', 'output directory')
.option('-a, --aspect-ratio <ratio>', 'aspect ratio (16:9 or 9:16)')
.parse(process.argv);
type Options = { input: string; output: string; aspectRatio: string };
const options = program.opts<Options>();
async function main() {
try {
// * Step 1: If no input file is provided, prompt the user to enter one
let input = options.input;
if (!input) {
const { _input } = await prompts(
{
type: 'text',
name: '_input',
message: 'Enter input file (.mp4)',
},
{ onCancel: () => process.exit(1) }
);
input = _input;
}
input = path.join(process.cwd(), input);
if (!fs.existsSync(input)) throw new Error(`No such file \`${input}\` exists`);
// * Step 2: Prompt the user to enter output directory
let output = options.output;
if (!output) {
const { _output } = await prompts(
{
type: 'text',
name: '_output',
message: 'Enter output directory',
},
{ onCancel: () => process.exit(1) }
);
output = _output;
}
output = path.join(process.cwd(), output);
if (fs.existsSync(output) && fs.readdirSync(output).length > 0) {
throw new Error(`output is not an empty directory: \`${output}\``);
}
// * Step 3: Create the output directory
await fs.promises.mkdir(output, { recursive: true });
console.log(chalk.green(`Created folder: ${output}`));
// * Step 4: If no aspect ratio is provided, prompt the user to pick one
let aspectRatio = options.aspectRatio;
if (!aspectRatio) {
const { _aspectRatio } = await prompts(
{
type: 'select',
name: '_aspectRatio',
message: 'Pick an aspect ratio:',
choices: [
{ title: '16:9', value: '16:9' },
{ title: '9:16', value: '9:16' },
],
},
{ onCancel: () => process.exit(1) }
);
aspectRatio = _aspectRatio;
}
console.log(chalk.green(`\nStarting video conversion for: ${chalk.bold(input)}`));
console.log(chalk.blue(`Output directory: ${chalk.bold(output)}`));
console.log(chalk.blue(`Aspect ratio: ${chalk.bold(aspectRatio)}\n`));
// * Step 5: Execute FFmpeg for each resolution with a loading spinner
await execFFmpeg(input, output, aspectRatio);
console.log(chalk.green('\nVideo conversion completed successfully! 🎉\n'));
} catch (error) {
console.error(chalk.red(error));
process.exit(1);
}
}
async function execFFmpeg(input: string, output: string, aspectRatio: string) {
let resolutions = [
{ resolution: '144p', width: 256, height: 144, bitrate: '250k' },
{ resolution: '240p', width: 426, height: 240, bitrate: '500k' },
{ resolution: '360p', width: 640, height: 360, bitrate: '800k' },
{ resolution: '480p', width: 854, height: 480, bitrate: '1400k' },
{ resolution: '720p', width: 1280, height: 720, bitrate: '2800k' },
{ resolution: '1080p', width: 1920, height: 1080, bitrate: '5000k' },
];
if (aspectRatio === '9:16') {
resolutions = resolutions.map(res => {
const temp = res.width;
res.width = res.height;
res.height = temp;
return res;
});
}
for (const { resolution, width, height, bitrate } of resolutions) {
const outFile = path.join(output, resolution);
const command = `
ffmpeg -hide_banner -y -i ${input} -vf scale=w=${width}:h=${height} \
-c:a aac -ar 48000 -c:v h264 -profile:v main -crf 20 -preset medium \
-b:v ${bitrate} -maxrate ${Math.floor(parseInt(bitrate) * 1.1)}k \
-bufsize ${Math.floor(parseInt(bitrate) * 1.5)}k \
-hls_time 4 -hls_playlist_type vod -b:a 128k \
-hls_segment_filename ${outFile}_%03d.ts ${outFile}.m3u8
`;
const spinner = ora(`Converting to ${resolution}...`).start();
await executeCommand(command.trim());
spinner.succeed(chalk.green(`Converted to ${resolution} successfully!`));
}
// * Generate the master playlist.m3u8
const masterPlaylistPath = path.join(output, 'playlist.m3u8');
const masterPlaylist = resolutions
.map(({ resolution, bitrate, width, height }) => {
return `#EXT-X-STREAM-INF:BANDWIDTH=${
parseInt(bitrate) * 1000
},RESOLUTION=${width}x${height}\n${resolution}.m3u8`;
})
.join('\n');
await fs.promises.writeFile(masterPlaylistPath, `#EXTM3U\n${masterPlaylist}`);
console.log(chalk.green(`Master playlist created at ${masterPlaylistPath}`));
}
function executeCommand(command: string) {
return new Promise<void>((resolve, reject) => {
exec(command, { maxBuffer: 10024 * 10024 }, err => {
if (err) reject(new Error(`Failed to execute command: ${err.message}`));
else resolve();
});
});
}
main();