@@ -0,0 +1,24 @@ | |||
type ProcessEvent = { type: string, phase: string, command?: string }; | |||
type ProcessEventCallback = (event: ProcessEvent) => void; | |||
type ErrorEventCallback = (event: Error) => void; | |||
export interface VideoClipEventEmitter extends NodeJS.EventEmitter { | |||
process(): void; | |||
on(eventType: 'process', callback: ProcessEventCallback): this; | |||
on(eventType: 'error', callback: ErrorEventCallback): this; | |||
on(eventType: 'end', callback: ErrorEventCallback): this; | |||
} | |||
export const FILE_TYPES: Record<string, string> = { | |||
mkv: 'video/x-matroska', | |||
webm: 'video/webm', | |||
mp4: 'video/mp4', | |||
}; | |||
export interface CreateBaseClipperParams { | |||
url: string; | |||
start?: number | string; | |||
end?: number | string; | |||
} |
@@ -0,0 +1,16 @@ | |||
export 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}`; | |||
}; | |||
export const convertDurationToSeconds = (d: string) => { | |||
const [hh, mm, ss] = d.split(':'); | |||
return (Number(hh) * 3600) + (Number(mm) * 60) + Number(ss); | |||
}; |
@@ -1,266 +1,27 @@ | |||
import { spawnSync } from 'child_process'; | |||
import { unlinkSync, readFileSync } from 'fs'; | |||
import { EventEmitter } from 'events'; | |||
import { CreateYouTubeClipperParams, YouTubeVideoClipEventEmitter } from './video-types/youtube'; | |||
import { VideoClipEventEmitter } from './common'; | |||
export enum VideoType { | |||
YOUTUBE = 'youtube', | |||
} | |||
interface CreateBaseClipperParams { | |||
url: string; | |||
start?: number | string; | |||
end?: number | string; | |||
postprocessorExecutablePath?: string; | |||
} | |||
interface CreateYouTubeClipperParams extends CreateBaseClipperParams { | |||
downloaderExecutablePath?: string; | |||
} | |||
export interface CreateVideoClipperParams extends CreateYouTubeClipperParams { | |||
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); | |||
}; | |||
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 VideoClipEventEmitter extends EventEmitter { | |||
process(): void; | |||
} | |||
class YouTubeVideoClipEventEmitter extends EventEmitter implements VideoClipEventEmitter { | |||
constructor(private readonly params: CreateYouTubeClipperParams) { | |||
super(); | |||
} | |||
private constructDefaultDownloadArgs(cacheFilename: string) { | |||
const startSeconds = normalizeStartSeconds(this.params.start); | |||
const downloadArgs = [ | |||
'--youtube-skip-dash-manifest', | |||
'-f', 'bestvideo+bestaudio', | |||
'--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', { | |||
type: 'download', | |||
phase: 'start', | |||
command: `${this.params.downloaderExecutablePath as string} ${downloadArgs.join(' ')}`, | |||
}); | |||
const downloaderProcess = spawnSync( | |||
this.params.downloaderExecutablePath as string, | |||
downloadArgs, | |||
); | |||
if (downloaderProcess.error) { | |||
this.emit('error', new Error('Downloader error.', { cause: downloaderProcess.error })); | |||
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', { | |||
type: 'video/webm', | |||
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; | |||
} | |||
const [videoUrlStream, audioUrlStream] = downloaderProcess.stdout.toString('utf-8').split('\n'); | |||
this.emit('process', { | |||
type: 'download', | |||
phase: 'success', | |||
streamUrls: { | |||
video: [videoUrlStream], | |||
audio: [audioUrlStream], | |||
}, | |||
}); | |||
const startSeconds = normalizeStartSeconds(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 cacheFilename = getCacheFilename(); // todo label this on the cache | |||
const ffmpegProcessArgs = [ | |||
...ffmpegProcessVideoStreamArgs, | |||
...ffmpegProcessAudioStreamArgs, | |||
'-map', '0:v', | |||
'-map', '1:a', | |||
cacheFilename, | |||
]; | |||
this.emit('process', { | |||
type: 'postprocess', | |||
phase: 'start', | |||
command: `${this.params.postprocessorExecutablePath as string} ${ffmpegProcessArgs.join(' ')}`, | |||
}); | |||
const ffmpegProcess = spawnSync( | |||
this.params.postprocessorExecutablePath as string, | |||
ffmpegProcessArgs, | |||
); | |||
if (ffmpegProcess.error) { | |||
this.emit('error', new Error('ffmpeg error.', { cause: ffmpegProcess.error })); | |||
this.emit('end'); | |||
return; | |||
} | |||
this.emit('process', { | |||
type: 'postprocess', | |||
phase: 'success', | |||
}); | |||
const output = readFileSync(cacheFilename); | |||
unlinkSync(cacheFilename); | |||
this.emit('success', { | |||
type: 'video/webm', | |||
output, | |||
}); | |||
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: CreateVideoClipperParams) => { | |||
export const createVideoClipper = (params: CreateVideoClipperParams): VideoClipEventEmitter => { | |||
const { | |||
type: videoType, | |||
url, | |||
start, | |||
end, | |||
downloaderExecutablePath, | |||
postprocessorExecutablePath, | |||
} = params; | |||
switch (videoType as string) { | |||
case VideoType.YOUTUBE: | |||
return new YouTubeVideoClipEventEmitter({ | |||
downloaderExecutablePath, | |||
postprocessorExecutablePath, | |||
url, | |||
start, | |||
end, | |||
@@ -271,3 +32,5 @@ export const createVideoClipper = (params: CreateVideoClipperParams) => { | |||
throw new TypeError(`Invalid video type: "${videoType}". Valid values are: ${JSON.stringify(Object.values(VideoType))}`); | |||
}; | |||
export { VideoClipEventEmitter }; |
@@ -0,0 +1,142 @@ | |||
import { EventEmitter } from 'events'; | |||
import { spawnSync } from 'child_process'; | |||
import { readFileSync, unlinkSync } from 'fs'; | |||
import { CreateBaseClipperParams, FILE_TYPES, VideoClipEventEmitter } from '../common'; | |||
import { convertDurationToSeconds, convertSecondsToDuration } from '../duration'; | |||
const normalizeStartSeconds = (start?: number | string) => { | |||
if (typeof start === 'undefined') { | |||
return 0; | |||
} | |||
if (typeof start === 'string') { | |||
return convertDurationToSeconds(start); | |||
} | |||
return start; | |||
}; | |||
const FORMAT_DEFAULT_ARGS = [ | |||
'--youtube-skip-dash-manifest', | |||
'-f', 'bestvideo+bestaudio', | |||
]; | |||
const getFileExtension = (downloaderExecutablePath: string, url: string) => { | |||
const result = spawnSync( | |||
downloaderExecutablePath, | |||
[ | |||
...FORMAT_DEFAULT_ARGS, | |||
'--print', 'filename', | |||
'-o', '%(ext)s', | |||
url, | |||
], | |||
); | |||
if (result.error) { | |||
throw result.error; | |||
} | |||
return result.stdout.toString('utf-8'); | |||
}; | |||
const constructDownloadSectionsRegex = (start?: number | string, end?: number | string) => { | |||
const startSeconds = normalizeStartSeconds(start); | |||
if (typeof end !== 'undefined') { | |||
const endSeconds = ( | |||
typeof end === 'string' | |||
? convertDurationToSeconds(end) | |||
: end | |||
); | |||
return `*${convertSecondsToDuration(startSeconds)}-${convertSecondsToDuration(endSeconds)}`; | |||
} | |||
if (startSeconds > 0) { | |||
return `*${convertSecondsToDuration(startSeconds)}-inf`; | |||
} | |||
return null; | |||
}; | |||
const constructDefaultDownloadArgs = ( | |||
outputFilename: string, | |||
url: string, | |||
start?: number | string, | |||
end?: number | string, | |||
) => { | |||
const defaultDownloadArgs = [ | |||
...FORMAT_DEFAULT_ARGS, | |||
'-o', outputFilename, | |||
]; | |||
const downloadSectionsRegex = constructDownloadSectionsRegex(start, end); | |||
if (typeof downloadSectionsRegex === 'string') { | |||
return [ | |||
...defaultDownloadArgs, | |||
'--force-keyframes-at-cuts', | |||
'--download-sections', downloadSectionsRegex, | |||
url, | |||
]; | |||
} | |||
return [ | |||
...defaultDownloadArgs, | |||
url, | |||
]; | |||
}; | |||
export interface CreateYouTubeClipperParams extends CreateBaseClipperParams { | |||
downloaderExecutablePath?: string; | |||
} | |||
export class YouTubeVideoClipEventEmitter extends EventEmitter implements VideoClipEventEmitter { | |||
constructor(private readonly params: CreateYouTubeClipperParams) { | |||
super(); | |||
} | |||
process() { | |||
if (!this.params.downloaderExecutablePath) { | |||
this.emit('error', new Error('Downloader not found.')); | |||
this.emit('end'); | |||
return; | |||
} | |||
const fileExtension = getFileExtension( | |||
this.params.downloaderExecutablePath, | |||
this.params.url, | |||
); | |||
const cacheFilename = `output.${fileExtension}`; // todo label this on the cache | |||
const downloadArgs = constructDefaultDownloadArgs( | |||
cacheFilename, | |||
this.params.url, | |||
this.params.start, | |||
this.params.end, | |||
); | |||
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', downloaderProcess.error); | |||
this.emit('end'); | |||
return; | |||
} | |||
this.emit('process', { | |||
type: 'download', | |||
phase: 'success', | |||
}); | |||
const output = readFileSync(cacheFilename); | |||
unlinkSync(cacheFilename); | |||
this.emit('success', { | |||
type: FILE_TYPES[fileExtension], | |||
output, | |||
}); | |||
this.emit('end'); | |||
} | |||
} |
@@ -1,113 +1,27 @@ | |||
import { | |||
describe, it, expect, vi, beforeEach, Mock, afterEach, | |||
describe, | |||
it, | |||
expect, | |||
vi, | |||
beforeEach, | |||
Mock, | |||
afterEach, | |||
} from 'vitest'; | |||
import { spawnSync } from 'child_process'; | |||
import { readFileSync, unlinkSync } from 'fs'; | |||
import * as webVideoClipCore from '../src'; | |||
import { VideoClipEventEmitter } from '../src'; | |||
vi.mock('child_process', () => ({ | |||
spawnSync: vi.fn(), | |||
})); | |||
vi.mock('child_process'); | |||
vi.mock('fs', () => ({ | |||
readFileSync: vi.fn(), | |||
unlinkSync: vi.fn(), | |||
})); | |||
vi.mock('fs'); | |||
describe('createVideoClipper', () => { | |||
beforeEach(() => { | |||
(readFileSync as Mock).mockReturnValueOnce(Buffer.from('')); | |||
}); | |||
describe('with postprocessing', () => { | |||
let clipper: VideoClipEventEmitter; | |||
beforeEach(() => { | |||
clipper = webVideoClipCore.createVideoClipper({ | |||
downloaderExecutablePath: 'yt-dlp', | |||
postprocessorExecutablePath: 'ffmpeg', | |||
type: webVideoClipCore.VideoType.YOUTUBE, | |||
url: 'https://www.youtube.com/watch?v=BaW_jenozKc', | |||
start: 0, | |||
end: 0, | |||
}); | |||
}); | |||
afterEach(() => { | |||
(spawnSync as Mock).mockReset(); | |||
}); | |||
it('calls the downloader function', () => new Promise((done) => { | |||
(spawnSync as Mock) | |||
.mockReturnValueOnce({ stdout: Buffer.from('') }) | |||
.mockReturnValueOnce({}); | |||
clipper.on('end', done); | |||
clipper.process(); | |||
expect(spawnSync).nthCalledWith(1, 'yt-dlp', expect.anything()); | |||
})); | |||
it('emits downloader errors', () => new Promise((done) => { | |||
const causeError = new Error('generic downloader message'); | |||
(spawnSync as Mock) | |||
.mockReturnValueOnce({ error: causeError }) | |||
.mockReturnValueOnce({}); | |||
clipper.on('error', (err: Error) => { | |||
expect(err.cause).toHaveProperty('message', causeError.message); | |||
}); | |||
clipper.on('end', done); | |||
clipper.process(); | |||
})); | |||
it('calls the postprocess function', () => new Promise((done) => { | |||
(spawnSync as Mock) | |||
.mockReturnValueOnce({ stdout: Buffer.from('') }) | |||
.mockReturnValueOnce({}); | |||
clipper.on('end', done); | |||
clipper.process(); | |||
expect(spawnSync).nthCalledWith(2, 'ffmpeg', expect.anything()); | |||
})); | |||
it('emits postprocess errors', () => new Promise((done) => { | |||
const causeError = new Error('generic postprocessor message'); | |||
(spawnSync as Mock) | |||
.mockReturnValueOnce({ stdout: Buffer.from('') }) | |||
.mockReturnValueOnce({ error: causeError }); | |||
clipper.on('error', (err: Error) => { | |||
expect(err.cause).toHaveProperty('message', causeError.message); | |||
}); | |||
clipper.on('end', done); | |||
clipper.process(); | |||
})); | |||
it('calls the buffer extract function', () => new Promise((done) => { | |||
(spawnSync as Mock) | |||
.mockReturnValueOnce({ stdout: Buffer.from('') }) | |||
.mockReturnValueOnce({}); | |||
clipper.on('end', done); | |||
clipper.process(); | |||
expect(readFileSync).toBeCalled(); | |||
})); | |||
it('calls the cleanup function', () => new Promise((done) => { | |||
(spawnSync as Mock) | |||
.mockReturnValueOnce({ stdout: Buffer.from('') }) | |||
.mockReturnValueOnce({}); | |||
clipper.on('end', done); | |||
clipper.process(); | |||
expect(unlinkSync).toBeCalled(); | |||
})); | |||
}); | |||
describe('without postprocessing', () => { | |||
let clipper: VideoClipEventEmitter; | |||
let clipper: webVideoClipCore.VideoClipEventEmitter; | |||
beforeEach(() => { | |||
clipper = webVideoClipCore.createVideoClipper({ | |||
downloaderExecutablePath: 'yt-dlp', | |||
@@ -124,23 +38,24 @@ describe('createVideoClipper', () => { | |||
it('calls the downloader function', () => new Promise((done) => { | |||
(spawnSync as Mock) | |||
.mockReturnValueOnce({ stdout: Buffer.from('') }) | |||
.mockReturnValueOnce({}); | |||
.mockReturnValueOnce({ stdout: Buffer.from('mkv') }) | |||
.mockReturnValueOnce({ stdout: Buffer.from('') }); | |||
clipper.on('end', done); | |||
clipper.process(); | |||
expect(spawnSync).toBeCalledTimes(1); | |||
expect(spawnSync).nthCalledWith(1, 'yt-dlp', expect.anything()); | |||
expect(spawnSync).toBeCalledTimes(2); | |||
expect(spawnSync).nthCalledWith(1, 'yt-dlp', expect.arrayContaining(['--print'])); | |||
expect(spawnSync).nthCalledWith(2, 'yt-dlp', expect.anything()); | |||
})); | |||
it('emits downloader errors', () => new Promise((done) => { | |||
const causeError = new Error('generic downloader message'); | |||
(spawnSync as Mock) | |||
.mockReturnValueOnce({ error: causeError }) | |||
.mockReturnValueOnce({}); | |||
.mockReturnValueOnce({ stdout: Buffer.from('mkv') }) | |||
.mockReturnValueOnce({ error: causeError }); | |||
clipper.on('error', (err: Error) => { | |||
expect(err.cause).toHaveProperty('message', causeError.message); | |||
expect(err).toHaveProperty('message', causeError.message); | |||
}); | |||
clipper.on('end', done); | |||
clipper.process(); | |||
@@ -148,8 +63,8 @@ describe('createVideoClipper', () => { | |||
it('calls the buffer extract function', () => new Promise((done) => { | |||
(spawnSync as Mock) | |||
.mockReturnValueOnce({ stdout: Buffer.from('') }) | |||
.mockReturnValueOnce({}); | |||
.mockReturnValueOnce({ stdout: Buffer.from('mkv') }) | |||
.mockReturnValueOnce({ stdout: Buffer.from('') }); | |||
clipper.on('end', done); | |||
clipper.process(); | |||
@@ -159,8 +74,8 @@ describe('createVideoClipper', () => { | |||
it('calls the cleanup function', () => new Promise((done) => { | |||
(spawnSync as Mock) | |||
.mockReturnValueOnce({ stdout: Buffer.from('') }) | |||
.mockReturnValueOnce({}); | |||
.mockReturnValueOnce({ stdout: Buffer.from('mkv') }) | |||
.mockReturnValueOnce({ stdout: Buffer.from('') }); | |||
clipper.on('end', done); | |||
clipper.process(); | |||
@@ -14,8 +14,7 @@ | |||
"noImplicitReturns": true, | |||
"noFallthroughCasesInSwitch": true, | |||
"moduleResolution": "node", | |||
"jsx": "react", | |||
"esModuleInterop": true, | |||
"target": "es2018" | |||
"target": "es2022" | |||
} | |||
} |
@@ -1,6 +1,6 @@ | |||
{ | |||
"exclude": ["node_modules"], | |||
"include": ["src", "types"], | |||
"include": ["src", "types", "test"], | |||
"compilerOptions": { | |||
"module": "ESNext", | |||
"lib": ["ESNext"], | |||
@@ -14,8 +14,7 @@ | |||
"noImplicitReturns": true, | |||
"noFallthroughCasesInSwitch": true, | |||
"moduleResolution": "node", | |||
"jsx": "react", | |||
"esModuleInterop": true, | |||
"target": "es2018" | |||
"target": "es2022" | |||
} | |||
} |