@@ -108,3 +108,4 @@ dist | |||
vendor | |||
test/*.webm | |||
.idea/ | |||
scenarios/ |
@@ -13,11 +13,14 @@ | |||
"pridepack" | |||
], | |||
"devDependencies": { | |||
"@types/dotenv": "^8.2.0", | |||
"@types/node": "^18.14.1", | |||
"dotenv": "^16.0.3", | |||
"eslint": "^8.35.0", | |||
"eslint-config-lxsmnsyc": "^0.5.0", | |||
"pridepack": "2.4.2", | |||
"tslib": "^2.5.0", | |||
"tsx": "^3.12.6", | |||
"typescript": "^4.9.5", | |||
"vitest": "^0.28.1" | |||
}, | |||
@@ -30,7 +33,8 @@ | |||
"watch": "pridepack watch", | |||
"start": "pridepack start", | |||
"dev": "pridepack dev", | |||
"test": "vitest" | |||
"test": "vitest", | |||
"tsx": "tsx" | |||
}, | |||
"private": false, | |||
"description": "Clip YouTube videos.", | |||
@@ -1,53 +1,173 @@ | |||
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, | |||
url: string; | |||
start?: number | string; | |||
end?: number | string; | |||
} | |||
interface ClipYouTubeVideoParams extends ClipVideoBaseParams { | |||
downloaderExecutablePath?: string; | |||
postprocessorExecutablePath?: string; | |||
} | |||
interface ClipVideoParams extends ClipYouTubeVideoParams { | |||
type: VideoType, | |||
} | |||
const clipYouTubeVideo = (params: ClipYouTubeVideoParams) => { | |||
if (!params.downloaderExecutablePath) { | |||
throw new Error('Downloader not found.'); | |||
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(); | |||
} | |||
spawnSync( | |||
params.downloaderExecutablePath, | |||
[ | |||
'--postprocessor-args', | |||
`-ss ${params.start.toString()} -to ${params.end.toString()}`, | |||
'-o', | |||
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', | |||
params.url, | |||
], | |||
); | |||
const output = readFileSync('output.webm'); | |||
unlinkSync('output.webm'); | |||
return output; | |||
}; | |||
]; | |||
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 clipVideo = (params: ClipVideoParams) => { | |||
export const createVideoClipper = (params: ClipVideoParams) => { | |||
const { | |||
type: videoType, url, start, end, | |||
type: videoType, | |||
url, | |||
start, | |||
end, | |||
downloaderExecutablePath, | |||
postprocessorExecutablePath, | |||
} = params; | |||
switch (videoType as string) { | |||
case VideoType.YOUTUBE: | |||
return clipYouTubeVideo({ | |||
return new YouTubeVideoClipEventEmitter({ | |||
downloaderExecutablePath, | |||
postprocessorExecutablePath, | |||
url, | |||
start, | |||
end, | |||
@@ -1,9 +1,10 @@ | |||
import { | |||
describe, it, expect, vi, beforeEach, Mock, | |||
describe, it, expect, vi, beforeEach, Mock, afterEach, | |||
} from 'vitest'; | |||
import { spawnSync } from 'child_process'; | |||
import { readFileSync, unlinkSync } from 'fs'; | |||
import * as youtubeClipCore from '../src'; | |||
import * as webVideoClipCore from '../src'; | |||
import { ClipEventEmitter } from '../src'; | |||
vi.mock('child_process', () => ({ | |||
spawnSync: vi.fn(), | |||
@@ -14,44 +15,92 @@ vi.mock('fs', () => ({ | |||
unlinkSync: vi.fn(), | |||
})); | |||
describe('clipVideo', () => { | |||
describe('createVideoClipper', () => { | |||
beforeEach(() => { | |||
(readFileSync as Mock).mockReturnValueOnce(Buffer.from('')); | |||
}); | |||
it('calls the downloader function', () => { | |||
youtubeClipCore.clipVideo({ | |||
let clipper: ClipEventEmitter; | |||
beforeEach(() => { | |||
clipper = webVideoClipCore.createVideoClipper({ | |||
downloaderExecutablePath: 'yt-dlp', | |||
type: youtubeClipCore.VideoType.YOUTUBE, | |||
postprocessorExecutablePath: 'ffmpeg', | |||
type: webVideoClipCore.VideoType.YOUTUBE, | |||
url: 'https://www.youtube.com/watch?v=BaW_jenozKc', | |||
start: 0, | |||
end: 0, | |||
}); | |||
}); | |||
expect(spawnSync).toBeCalledWith('yt-dlp', expect.anything()); | |||
afterEach(() => { | |||
(spawnSync as Mock).mockReset(); | |||
}); | |||
it('calls the buffer extract function', () => { | |||
youtubeClipCore.clipVideo({ | |||
downloaderExecutablePath: 'yt-dlp', | |||
type: youtubeClipCore.VideoType.YOUTUBE, | |||
url: 'https://www.youtube.com/watch?v=BaW_jenozKc', | |||
start: 0, | |||
end: 0, | |||
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(); | |||
})); | |||
expect(readFileSync).toBeCalled(); | |||
}); | |||
it('calls the postprocess function', () => new Promise((done) => { | |||
(spawnSync as Mock) | |||
.mockReturnValueOnce({ stdout: Buffer.from('') }) | |||
.mockReturnValueOnce({}); | |||
it('calls the cleanup function', () => { | |||
youtubeClipCore.clipVideo({ | |||
downloaderExecutablePath: 'yt-dlp', | |||
type: youtubeClipCore.VideoType.YOUTUBE, | |||
url: 'https://www.youtube.com/watch?v=BaW_jenozKc', | |||
start: 0, | |||
end: 0, | |||
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(); | |||
}); | |||
})); | |||
}); |
@@ -1,15 +0,0 @@ | |||
const { clipVideo, VideoType } = require('../dist/cjs/development'); | |||
const fs = require('fs'); | |||
const dotenv = require('dotenv'); | |||
dotenv.config(); | |||
const clippedVideoBuffer = clipVideo({ | |||
downloaderExecutablePath: process.env.YOUTUBE_DOWNLOADER_EXECUTABLE_PATH, | |||
type: VideoType.YOUTUBE, | |||
url: 'https://www.youtube.com/watch?v=BaW_jenozKc', | |||
start: 0, | |||
end: 1, | |||
}); | |||
fs.writeFileSync('output.webm', clippedVideoBuffer); |
@@ -233,6 +233,30 @@ | |||
"@babel/helper-validator-identifier" "^7.19.1" | |||
to-fast-properties "^2.0.0" | |||
"@esbuild-kit/cjs-loader@^2.4.2": | |||
version "2.4.2" | |||
resolved "https://registry.yarnpkg.com/@esbuild-kit/cjs-loader/-/cjs-loader-2.4.2.tgz#cb4dde00fbf744a68c4f20162ea15a8242d0fa54" | |||
integrity sha512-BDXFbYOJzT/NBEtp71cvsrGPwGAMGRB/349rwKuoxNSiKjPraNNnlK6MIIabViCjqZugu6j+xeMDlEkWdHHJSg== | |||
dependencies: | |||
"@esbuild-kit/core-utils" "^3.0.0" | |||
get-tsconfig "^4.4.0" | |||
"@esbuild-kit/core-utils@^3.0.0": | |||
version "3.1.0" | |||
resolved "https://registry.yarnpkg.com/@esbuild-kit/core-utils/-/core-utils-3.1.0.tgz#49945d533dbd5e1b7620aa0fc522c15e6ec089c5" | |||
integrity sha512-Uuk8RpCg/7fdHSceR1M6XbSZFSuMrxcePFuGgyvsBn+u339dk5OeL4jv2EojwTN2st/unJGsVm4qHWjWNmJ/tw== | |||
dependencies: | |||
esbuild "~0.17.6" | |||
source-map-support "^0.5.21" | |||
"@esbuild-kit/esm-loader@^2.5.5": | |||
version "2.5.5" | |||
resolved "https://registry.yarnpkg.com/@esbuild-kit/esm-loader/-/esm-loader-2.5.5.tgz#b82da14fcee3fc1d219869756c06f43f67d1ca71" | |||
integrity sha512-Qwfvj/qoPbClxCRNuac1Du01r9gvNOT+pMYtJDapfB1eoGN1YlJ1BixLyL9WVENRx5RXgNLdfYdx/CuswlGhMw== | |||
dependencies: | |||
"@esbuild-kit/core-utils" "^3.0.0" | |||
get-tsconfig "^4.4.0" | |||
"@esbuild/android-arm64@0.17.12": | |||
version "0.17.12" | |||
resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.17.12.tgz#15a8e2b407d03989b899e325151dc2e96d19c620" | |||
@@ -508,6 +532,13 @@ | |||
resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.4.tgz#e913e8175db8307d78b4e8fa690408ba6b65dee4" | |||
integrity sha512-KnRanxnpfpjUTqTCXslZSEdLfXExwgNxYPdiO2WGUj8+HDjFi8R3k5RVKPeSCzLjCcshCAtVO2QBbVuAV4kTnw== | |||
"@types/dotenv@^8.2.0": | |||
version "8.2.0" | |||
resolved "https://registry.yarnpkg.com/@types/dotenv/-/dotenv-8.2.0.tgz#5cd64710c3c98e82d9d15844375a33bf1b45d053" | |||
integrity sha512-ylSC9GhfRH7m1EUXBXofhgx4lUWmFeQDINW5oLuS+gxWdfUeW4zJdeVTYVkexEW+e2VUvlZR2kGnGGipAWR7kw== | |||
dependencies: | |||
dotenv "*" | |||
"@types/json-schema@^7.0.9": | |||
version "7.0.11" | |||
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.11.tgz#d421b6c527a3037f7c84433fd2c4229e016863d3" | |||
@@ -1180,7 +1211,7 @@ dot-prop@^5.2.0: | |||
dependencies: | |||
is-obj "^2.0.0" | |||
dotenv@^16.0.3: | |||
dotenv@*, dotenv@^16.0.3: | |||
version "16.0.3" | |||
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.0.3.tgz#115aec42bac5053db3c456db30cc243a5a836a07" | |||
integrity sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ== | |||
@@ -1293,7 +1324,7 @@ es-to-primitive@^1.2.1: | |||
is-date-object "^1.0.1" | |||
is-symbol "^1.0.2" | |||
esbuild@^0.17.4, esbuild@^0.17.5: | |||
esbuild@^0.17.4, esbuild@^0.17.5, esbuild@~0.17.6: | |||
version "0.17.12" | |||
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.17.12.tgz#2ad7523bf1bc01881e9d904bc04e693bd3bdcf2f" | |||
integrity sha512-bX/zHl7Gn2CpQwcMtRogTTBf9l1nl+H6R8nUbjk+RuKqAE3+8FDulLA+pHvX7aA7Xe07Iwa+CWvy9I8Y2qqPKQ== | |||
@@ -1807,7 +1838,7 @@ get-symbol-description@^1.0.0: | |||
call-bind "^1.0.2" | |||
get-intrinsic "^1.1.1" | |||
get-tsconfig@^4.2.0: | |||
get-tsconfig@^4.2.0, get-tsconfig@^4.4.0: | |||
version "4.4.0" | |||
resolved "https://registry.yarnpkg.com/get-tsconfig/-/get-tsconfig-4.4.0.tgz#64eee64596668a81b8fce18403f94f245ee0d4e5" | |||
integrity sha512-0Gdjo/9+FzsYhXCEFueo2aY1z1tpXrxWZzP7k8ul9qt1U5o8rYJwTJYmaeHdrVosYIVYkOy2iwCJ9FdpocJhPQ== | |||
@@ -3240,6 +3271,17 @@ tsutils@^3.21.0: | |||
dependencies: | |||
tslib "^1.8.1" | |||
tsx@^3.12.6: | |||
version "3.12.6" | |||
resolved "https://registry.yarnpkg.com/tsx/-/tsx-3.12.6.tgz#36b3693e48b8392da374487190972c7b80e433b4" | |||
integrity sha512-q93WgS3lBdHlPgS0h1i+87Pt6n9K/qULIMNYZo07nSeu2z5QE2CellcAZfofVXBo2tQg9av2ZcRMQ2S2i5oadQ== | |||
dependencies: | |||
"@esbuild-kit/cjs-loader" "^2.4.2" | |||
"@esbuild-kit/core-utils" "^3.0.0" | |||
"@esbuild-kit/esm-loader" "^2.5.5" | |||
optionalDependencies: | |||
fsevents "~2.3.2" | |||
type-check@^0.4.0, type-check@~0.4.0: | |||
version "0.4.0" | |||
resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" | |||