|
- import { spawnSync } from 'child_process';
- import { unlinkSync, readFileSync } from 'fs';
- import { EventEmitter } from 'events';
-
- export enum VideoType {
- YOUTUBE = 'youtube',
- }
-
- interface ClipVideoBaseParams {
- url: string;
- start?: number | string;
- end?: number | string;
- }
-
- interface ClipYouTubeVideoParams extends ClipVideoBaseParams {
- downloaderExecutablePath?: string;
- postprocessorExecutablePath?: string;
- }
-
- interface ClipVideoParams extends ClipYouTubeVideoParams {
- type: VideoType,
- }
-
- const convertSecondsToDuration = (s: number) => {
- const milliseconds = (s - Math.floor(s)) * 1000;
- const seconds = s % 60;
- const minutes = Math.floor(s / 60) % 60;
- const hours = Math.floor(s / 3600);
- const sss = milliseconds.toString().padStart(3, '0');
- const ss = seconds.toString().padStart(2, '0');
- const mm = minutes.toString().padStart(2, '0');
- const hh = hours.toString().padStart(2, '0');
- return `${hh}:${mm}:${ss}.${sss}`;
- };
-
- const convertDurationToSeconds = (d: string) => {
- const [hh, mm, ss] = d.split(':');
- return (Number(hh) * 3600) + (Number(mm) * 60) + Number(ss);
- };
-
- export interface ClipEventEmitter extends EventEmitter {
- process(): void;
- }
-
- class YouTubeVideoClipEventEmitter extends EventEmitter implements ClipEventEmitter {
- constructor(private readonly params: ClipYouTubeVideoParams) {
- super();
- }
-
- process() {
- if (!this.params.downloaderExecutablePath) {
- this.emit('error', new Error('Downloader not found.'));
- this.emit('end', null);
- return;
- }
-
- if (!this.params.postprocessorExecutablePath) {
- this.emit('error', new Error('ffmpeg not found.'));
- this.emit('end', null);
- return;
- }
-
- const downloadArgs = [
- '--youtube-skip-dash-manifest',
- '-f', 'bestvideo+bestaudio',
- '-g', this.params.url,
- ];
- this.emit('process', {
- type: 'download',
- phase: 'start',
- command: `${this.params.downloaderExecutablePath} ${downloadArgs.join(' ')}`,
- });
- const downloaderProcess = spawnSync(this.params.downloaderExecutablePath, downloadArgs);
- if (downloaderProcess.error) {
- this.emit('error', new Error('Downloader error.', { cause: downloaderProcess.error }));
- this.emit('end', null);
- return;
- }
-
- const [videoUrlStream, audioUrlStream] = downloaderProcess.stdout.toString('utf-8').split('\n');
- this.emit('process', {
- type: 'download',
- phase: 'success',
- streamUrls: {
- video: [videoUrlStream],
- audio: [audioUrlStream],
- },
- });
-
- let startSeconds: number;
- if (typeof this.params.start === 'undefined') {
- startSeconds = 0;
- } else if (typeof this.params.start === 'string') {
- startSeconds = convertDurationToSeconds(this.params.start);
- } else {
- startSeconds = this.params.start;
- }
-
- const ffmpegProcessVideoStreamArgs = [
- '-ss', (this.params.start ?? '00:00:00').toString(),
- '-i', videoUrlStream,
- ];
-
- const ffmpegProcessAudioStreamArgs = [
- '-ss', (this.params.start ?? '00:00:00').toString(),
- '-i', audioUrlStream,
- ];
-
- if (typeof this.params.end !== 'undefined') {
- // -to flag is broken on video stream in ffmpeg.....
- const endSeconds = (
- typeof this.params.end === 'string'
- ? convertDurationToSeconds(this.params.end)
- : this.params.end
- );
-
- const difference = endSeconds - startSeconds;
- const clipDuration = convertSecondsToDuration(difference);
-
- ffmpegProcessVideoStreamArgs.push('-t');
- ffmpegProcessVideoStreamArgs.push(clipDuration);
-
- ffmpegProcessAudioStreamArgs.push('-t');
- ffmpegProcessAudioStreamArgs.push(clipDuration);
- }
-
- const ffmpegProcessArgs = [
- ...ffmpegProcessVideoStreamArgs,
- ...ffmpegProcessAudioStreamArgs,
- '-map', '0:v',
- '-map', '1:a',
- 'output.webm',
- ];
- this.emit('process', {
- type: 'postprocess',
- phase: 'start',
- command: `${this.params.postprocessorExecutablePath} ${ffmpegProcessArgs.join(' ')}`,
- });
- const ffmpegProcess = spawnSync(this.params.postprocessorExecutablePath, ffmpegProcessArgs);
- if (ffmpegProcess.error) {
- this.emit('error', new Error('ffmpeg error.', { cause: ffmpegProcess.error }));
- this.emit('end');
- return;
- }
- const output = readFileSync('output.webm');
- unlinkSync('output.webm');
- this.emit('process', {
- type: 'postprocess',
- phase: 'success',
- output,
- });
- this.emit('end');
- }
- }
-
- export const createVideoClipper = (params: ClipVideoParams) => {
- const {
- type: videoType,
- url,
- start,
- end,
- downloaderExecutablePath,
- postprocessorExecutablePath,
- } = params;
-
- switch (videoType as string) {
- case VideoType.YOUTUBE:
- return new YouTubeVideoClipEventEmitter({
- downloaderExecutablePath,
- postprocessorExecutablePath,
- url,
- start,
- end,
- });
- default:
- break;
- }
-
- throw new TypeError(`Invalid video type: "${videoType}". Valid values are: ${JSON.stringify(Object.values(VideoType))}`);
- };
|