Browse Source

Update implementation

Use postprocessor to download video to conserve data.
master
TheoryOfNekomata 1 year ago
parent
commit
128a70e805
6 changed files with 266 additions and 65 deletions
  1. +1
    -0
      .gitignore
  2. +5
    -1
      package.json
  3. +142
    -22
      src/index.ts
  4. +73
    -24
      test/index.test.ts
  5. +0
    -15
      test/try-service.js
  6. +45
    -3
      yarn.lock

+ 1
- 0
.gitignore View File

@@ -108,3 +108,4 @@ dist
vendor
test/*.webm
.idea/
scenarios/

+ 5
- 1
package.json View File

@@ -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.",


+ 142
- 22
src/index.ts View File

@@ -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,


+ 73
- 24
test/index.test.ts View File

@@ -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();
});
}));
});

+ 0
- 15
test/try-service.js View File

@@ -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);

+ 45
- 3
yarn.lock View File

@@ -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"


Loading…
Cancel
Save