|
@@ -10,11 +10,11 @@ interface ClipVideoBaseParams { |
|
|
url: string; |
|
|
url: string; |
|
|
start?: number | string; |
|
|
start?: number | string; |
|
|
end?: number | string; |
|
|
end?: number | string; |
|
|
|
|
|
postprocessorExecutablePath?: string; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
interface ClipYouTubeVideoParams extends ClipVideoBaseParams { |
|
|
interface ClipYouTubeVideoParams extends ClipVideoBaseParams { |
|
|
downloaderExecutablePath?: string; |
|
|
downloaderExecutablePath?: string; |
|
|
postprocessorExecutablePath?: string; |
|
|
|
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
interface ClipVideoParams extends ClipYouTubeVideoParams { |
|
|
interface ClipVideoParams extends ClipYouTubeVideoParams { |
|
@@ -38,6 +38,22 @@ const convertDurationToSeconds = (d: string) => { |
|
|
return (Number(hh) * 3600) + (Number(mm) * 60) + Number(ss); |
|
|
return (Number(hh) * 3600) + (Number(mm) * 60) + Number(ss); |
|
|
}; |
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
const normalizeStartSeconds = (start?: number | string) => { |
|
|
|
|
|
if (typeof start === 'undefined') { |
|
|
|
|
|
return 0; |
|
|
|
|
|
} |
|
|
|
|
|
if (typeof start === 'string') { |
|
|
|
|
|
return convertDurationToSeconds(start); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
return start; |
|
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
const getCacheFilename = () => { |
|
|
|
|
|
const time = new Date().getTime(); |
|
|
|
|
|
return `output-${time}.mkv`; |
|
|
|
|
|
}; |
|
|
|
|
|
|
|
|
export interface ClipEventEmitter extends EventEmitter { |
|
|
export interface ClipEventEmitter extends EventEmitter { |
|
|
process(): void; |
|
|
process(): void; |
|
|
} |
|
|
} |
|
@@ -47,33 +63,96 @@ class YouTubeVideoClipEventEmitter extends EventEmitter implements ClipEventEmit |
|
|
super(); |
|
|
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; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
private constructDefaultDownloadArgs(cacheFilename: string) { |
|
|
|
|
|
const startSeconds = normalizeStartSeconds(this.params.start); |
|
|
|
|
|
|
|
|
const downloadArgs = [ |
|
|
const downloadArgs = [ |
|
|
'--youtube-skip-dash-manifest', |
|
|
'--youtube-skip-dash-manifest', |
|
|
'-f', 'bestvideo+bestaudio', |
|
|
'-f', 'bestvideo+bestaudio', |
|
|
'-g', this.params.url, |
|
|
|
|
|
|
|
|
'--fragment-retries', 'infinite', |
|
|
]; |
|
|
]; |
|
|
|
|
|
|
|
|
|
|
|
if (typeof this.params.end !== 'undefined') { |
|
|
|
|
|
downloadArgs.push('--download-sections'); |
|
|
|
|
|
const endSeconds = ( |
|
|
|
|
|
typeof this.params.end === 'string' |
|
|
|
|
|
? convertDurationToSeconds(this.params.end) |
|
|
|
|
|
: this.params.end |
|
|
|
|
|
); |
|
|
|
|
|
downloadArgs.push( |
|
|
|
|
|
`*${convertSecondsToDuration(startSeconds)}-${convertSecondsToDuration(endSeconds)}`, |
|
|
|
|
|
); |
|
|
|
|
|
downloadArgs.push('-o'); |
|
|
|
|
|
downloadArgs.push(cacheFilename); |
|
|
|
|
|
downloadArgs.push('--force-keyframes-at-cuts'); |
|
|
|
|
|
if (startSeconds > 0) { |
|
|
|
|
|
downloadArgs.push(`${this.params.url}?t=${startSeconds}`); |
|
|
|
|
|
} else { |
|
|
|
|
|
downloadArgs.push(this.params.url); |
|
|
|
|
|
} |
|
|
|
|
|
} else if (startSeconds > 0) { |
|
|
|
|
|
downloadArgs.push('--download-sections'); |
|
|
|
|
|
downloadArgs.push(`*${convertSecondsToDuration(startSeconds)}-inf`); |
|
|
|
|
|
downloadArgs.push('-o'); |
|
|
|
|
|
downloadArgs.push(cacheFilename); |
|
|
|
|
|
downloadArgs.push('--force-keyframes-at-cuts'); |
|
|
|
|
|
downloadArgs.push(`${this.params.url}?t=${startSeconds}`); |
|
|
|
|
|
} else { |
|
|
|
|
|
downloadArgs.push('-o'); |
|
|
|
|
|
downloadArgs.push(cacheFilename); |
|
|
|
|
|
downloadArgs.push(this.params.url); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
return downloadArgs; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
private doDownloadProcess(downloadArgs: string[]) { |
|
|
this.emit('process', { |
|
|
this.emit('process', { |
|
|
type: 'download', |
|
|
type: 'download', |
|
|
phase: 'start', |
|
|
phase: 'start', |
|
|
command: `${this.params.downloaderExecutablePath} ${downloadArgs.join(' ')}`, |
|
|
|
|
|
|
|
|
command: `${this.params.downloaderExecutablePath as string} ${downloadArgs.join(' ')}`, |
|
|
}); |
|
|
}); |
|
|
const downloaderProcess = spawnSync(this.params.downloaderExecutablePath, downloadArgs); |
|
|
|
|
|
|
|
|
const downloaderProcess = spawnSync( |
|
|
|
|
|
this.params.downloaderExecutablePath as string, |
|
|
|
|
|
downloadArgs, |
|
|
|
|
|
); |
|
|
if (downloaderProcess.error) { |
|
|
if (downloaderProcess.error) { |
|
|
this.emit('error', new Error('Downloader error.', { cause: downloaderProcess.error })); |
|
|
this.emit('error', new Error('Downloader error.', { cause: downloaderProcess.error })); |
|
|
this.emit('end', null); |
|
|
this.emit('end', null); |
|
|
|
|
|
return null; |
|
|
|
|
|
} |
|
|
|
|
|
return downloaderProcess; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
private processDefault() { |
|
|
|
|
|
const cacheFilename = getCacheFilename(); // todo label this on the cache |
|
|
|
|
|
const downloadArgs = this.constructDefaultDownloadArgs(cacheFilename); |
|
|
|
|
|
const downloaderProcess = this.doDownloadProcess(downloadArgs); |
|
|
|
|
|
if (!downloaderProcess) { |
|
|
|
|
|
return; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
this.emit('process', { |
|
|
|
|
|
type: 'download', |
|
|
|
|
|
phase: 'success', |
|
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
|
|
const output = readFileSync(cacheFilename); |
|
|
|
|
|
unlinkSync(cacheFilename); |
|
|
|
|
|
this.emit('success', { |
|
|
|
|
|
output, |
|
|
|
|
|
}); |
|
|
|
|
|
this.emit('end'); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
private processWithPostprocessing() { |
|
|
|
|
|
const downloadArgs = [ |
|
|
|
|
|
'--youtube-skip-dash-manifest', |
|
|
|
|
|
'-f', 'bestvideo+bestaudio', |
|
|
|
|
|
'-g', this.params.url, |
|
|
|
|
|
]; |
|
|
|
|
|
const downloaderProcess = this.doDownloadProcess(downloadArgs); |
|
|
|
|
|
if (!downloaderProcess) { |
|
|
return; |
|
|
return; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
@@ -87,15 +166,7 @@ class YouTubeVideoClipEventEmitter extends EventEmitter implements ClipEventEmit |
|
|
}, |
|
|
}, |
|
|
}); |
|
|
}); |
|
|
|
|
|
|
|
|
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 startSeconds = normalizeStartSeconds(this.params.start); |
|
|
const ffmpegProcessVideoStreamArgs = [ |
|
|
const ffmpegProcessVideoStreamArgs = [ |
|
|
'-ss', (this.params.start ?? '00:00:00').toString(), |
|
|
'-ss', (this.params.start ?? '00:00:00').toString(), |
|
|
'-i', videoUrlStream, |
|
|
'-i', videoUrlStream, |
|
@@ -124,7 +195,7 @@ class YouTubeVideoClipEventEmitter extends EventEmitter implements ClipEventEmit |
|
|
ffmpegProcessAudioStreamArgs.push(clipDuration); |
|
|
ffmpegProcessAudioStreamArgs.push(clipDuration); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const cacheFilename = 'output.webm'; // todo label this on the cache |
|
|
|
|
|
|
|
|
const cacheFilename = getCacheFilename(); // todo label this on the cache |
|
|
const ffmpegProcessArgs = [ |
|
|
const ffmpegProcessArgs = [ |
|
|
...ffmpegProcessVideoStreamArgs, |
|
|
...ffmpegProcessVideoStreamArgs, |
|
|
...ffmpegProcessAudioStreamArgs, |
|
|
...ffmpegProcessAudioStreamArgs, |
|
@@ -135,23 +206,42 @@ class YouTubeVideoClipEventEmitter extends EventEmitter implements ClipEventEmit |
|
|
this.emit('process', { |
|
|
this.emit('process', { |
|
|
type: 'postprocess', |
|
|
type: 'postprocess', |
|
|
phase: 'start', |
|
|
phase: 'start', |
|
|
command: `${this.params.postprocessorExecutablePath} ${ffmpegProcessArgs.join(' ')}`, |
|
|
|
|
|
|
|
|
command: `${this.params.postprocessorExecutablePath as string} ${ffmpegProcessArgs.join(' ')}`, |
|
|
}); |
|
|
}); |
|
|
const ffmpegProcess = spawnSync(this.params.postprocessorExecutablePath, ffmpegProcessArgs); |
|
|
|
|
|
|
|
|
const ffmpegProcess = spawnSync( |
|
|
|
|
|
this.params.postprocessorExecutablePath as string, |
|
|
|
|
|
ffmpegProcessArgs, |
|
|
|
|
|
); |
|
|
if (ffmpegProcess.error) { |
|
|
if (ffmpegProcess.error) { |
|
|
this.emit('error', new Error('ffmpeg error.', { cause: ffmpegProcess.error })); |
|
|
this.emit('error', new Error('ffmpeg error.', { cause: ffmpegProcess.error })); |
|
|
this.emit('end'); |
|
|
this.emit('end'); |
|
|
return; |
|
|
return; |
|
|
} |
|
|
} |
|
|
const output = readFileSync(cacheFilename); |
|
|
|
|
|
unlinkSync(cacheFilename); |
|
|
|
|
|
this.emit('process', { |
|
|
this.emit('process', { |
|
|
type: 'postprocess', |
|
|
type: 'postprocess', |
|
|
phase: 'success', |
|
|
phase: 'success', |
|
|
|
|
|
}); |
|
|
|
|
|
const output = readFileSync(cacheFilename); |
|
|
|
|
|
unlinkSync(cacheFilename); |
|
|
|
|
|
this.emit('success', { |
|
|
output, |
|
|
output, |
|
|
}); |
|
|
}); |
|
|
this.emit('end'); |
|
|
this.emit('end'); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
process() { |
|
|
|
|
|
if (!this.params.downloaderExecutablePath) { |
|
|
|
|
|
this.emit('error', new Error('Downloader not found.')); |
|
|
|
|
|
this.emit('end', null); |
|
|
|
|
|
return; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
if (this.params.postprocessorExecutablePath) { |
|
|
|
|
|
this.processWithPostprocessing(); |
|
|
|
|
|
return; |
|
|
|
|
|
} |
|
|
|
|
|
this.processDefault(); |
|
|
|
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
export const createVideoClipper = (params: ClipVideoParams) => { |
|
|
export const createVideoClipper = (params: ClipVideoParams) => { |
|
|