Clip Web videos.
Nevar pievienot vairāk kā 25 tēmas Tēmai ir jāsākas ar burtu vai ciparu, tā var saturēt domu zīmes ('-') un var būt līdz 35 simboliem gara.

181 rinda
5.0 KiB

  1. import { spawnSync } from 'child_process';
  2. import { unlinkSync, readFileSync } from 'fs';
  3. import { EventEmitter } from 'events';
  4. export enum VideoType {
  5. YOUTUBE = 'youtube',
  6. }
  7. interface ClipVideoBaseParams {
  8. url: string;
  9. start?: number | string;
  10. end?: number | string;
  11. }
  12. interface ClipYouTubeVideoParams extends ClipVideoBaseParams {
  13. downloaderExecutablePath?: string;
  14. postprocessorExecutablePath?: string;
  15. }
  16. interface ClipVideoParams extends ClipYouTubeVideoParams {
  17. type: VideoType,
  18. }
  19. const convertSecondsToDuration = (s: number) => {
  20. const milliseconds = (s - Math.floor(s)) * 1000;
  21. const seconds = s % 60;
  22. const minutes = Math.floor(s / 60) % 60;
  23. const hours = Math.floor(s / 3600);
  24. const sss = milliseconds.toString().padStart(3, '0');
  25. const ss = seconds.toString().padStart(2, '0');
  26. const mm = minutes.toString().padStart(2, '0');
  27. const hh = hours.toString().padStart(2, '0');
  28. return `${hh}:${mm}:${ss}.${sss}`;
  29. };
  30. const convertDurationToSeconds = (d: string) => {
  31. const [hh, mm, ss] = d.split(':');
  32. return (Number(hh) * 3600) + (Number(mm) * 60) + Number(ss);
  33. };
  34. export interface ClipEventEmitter extends EventEmitter {
  35. process(): void;
  36. }
  37. class YouTubeVideoClipEventEmitter extends EventEmitter implements ClipEventEmitter {
  38. constructor(private readonly params: ClipYouTubeVideoParams) {
  39. super();
  40. }
  41. process() {
  42. if (!this.params.downloaderExecutablePath) {
  43. this.emit('error', new Error('Downloader not found.'));
  44. this.emit('end', null);
  45. return;
  46. }
  47. if (!this.params.postprocessorExecutablePath) {
  48. this.emit('error', new Error('ffmpeg not found.'));
  49. this.emit('end', null);
  50. return;
  51. }
  52. const downloadArgs = [
  53. '--youtube-skip-dash-manifest',
  54. '-f', 'bestvideo+bestaudio',
  55. '-g', this.params.url,
  56. ];
  57. this.emit('process', {
  58. type: 'download',
  59. phase: 'start',
  60. command: `${this.params.downloaderExecutablePath} ${downloadArgs.join(' ')}`,
  61. });
  62. const downloaderProcess = spawnSync(this.params.downloaderExecutablePath, downloadArgs);
  63. if (downloaderProcess.error) {
  64. this.emit('error', new Error('Downloader error.', { cause: downloaderProcess.error }));
  65. this.emit('end', null);
  66. return;
  67. }
  68. const [videoUrlStream, audioUrlStream] = downloaderProcess.stdout.toString('utf-8').split('\n');
  69. this.emit('process', {
  70. type: 'download',
  71. phase: 'success',
  72. streamUrls: {
  73. video: [videoUrlStream],
  74. audio: [audioUrlStream],
  75. },
  76. });
  77. let startSeconds: number;
  78. if (typeof this.params.start === 'undefined') {
  79. startSeconds = 0;
  80. } else if (typeof this.params.start === 'string') {
  81. startSeconds = convertDurationToSeconds(this.params.start);
  82. } else {
  83. startSeconds = this.params.start;
  84. }
  85. const ffmpegProcessVideoStreamArgs = [
  86. '-ss', (this.params.start ?? '00:00:00').toString(),
  87. '-i', videoUrlStream,
  88. ];
  89. const ffmpegProcessAudioStreamArgs = [
  90. '-ss', (this.params.start ?? '00:00:00').toString(),
  91. '-i', audioUrlStream,
  92. ];
  93. if (typeof this.params.end !== 'undefined') {
  94. // -to flag is broken on video stream in ffmpeg.....
  95. const endSeconds = (
  96. typeof this.params.end === 'string'
  97. ? convertDurationToSeconds(this.params.end)
  98. : this.params.end
  99. );
  100. const difference = endSeconds - startSeconds;
  101. const clipDuration = convertSecondsToDuration(difference);
  102. ffmpegProcessVideoStreamArgs.push('-t');
  103. ffmpegProcessVideoStreamArgs.push(clipDuration);
  104. ffmpegProcessAudioStreamArgs.push('-t');
  105. ffmpegProcessAudioStreamArgs.push(clipDuration);
  106. }
  107. const ffmpegProcessArgs = [
  108. ...ffmpegProcessVideoStreamArgs,
  109. ...ffmpegProcessAudioStreamArgs,
  110. '-map', '0:v',
  111. '-map', '1:a',
  112. 'output.webm',
  113. ];
  114. this.emit('process', {
  115. type: 'postprocess',
  116. phase: 'start',
  117. command: `${this.params.postprocessorExecutablePath} ${ffmpegProcessArgs.join(' ')}`,
  118. });
  119. const ffmpegProcess = spawnSync(this.params.postprocessorExecutablePath, ffmpegProcessArgs);
  120. if (ffmpegProcess.error) {
  121. this.emit('error', new Error('ffmpeg error.', { cause: ffmpegProcess.error }));
  122. this.emit('end');
  123. return;
  124. }
  125. const output = readFileSync('output.webm');
  126. unlinkSync('output.webm');
  127. this.emit('process', {
  128. type: 'postprocess',
  129. phase: 'success',
  130. output,
  131. });
  132. this.emit('end');
  133. }
  134. }
  135. export const createVideoClipper = (params: ClipVideoParams) => {
  136. const {
  137. type: videoType,
  138. url,
  139. start,
  140. end,
  141. downloaderExecutablePath,
  142. postprocessorExecutablePath,
  143. } = params;
  144. switch (videoType as string) {
  145. case VideoType.YOUTUBE:
  146. return new YouTubeVideoClipEventEmitter({
  147. downloaderExecutablePath,
  148. postprocessorExecutablePath,
  149. url,
  150. start,
  151. end,
  152. });
  153. default:
  154. break;
  155. }
  156. throw new TypeError(`Invalid video type: "${videoType}". Valid values are: ${JSON.stringify(Object.values(VideoType))}`);
  157. };