Переглянути джерело

Update code

Use --force-keyframes-at-cuts to have a smooth cut.
master
TheoryOfNekomata 1 рік тому
джерело
коміт
86963b6b53
2 змінених файлів з 261 додано та 106 видалено
  1. +120
    -30
      src/index.ts
  2. +141
    -76
      test/index.test.ts

+ 120
- 30
src/index.ts Переглянути файл

@@ -10,11 +10,11 @@ interface ClipVideoBaseParams {
url: string;
start?: number | string;
end?: number | string;
postprocessorExecutablePath?: string;
}

interface ClipYouTubeVideoParams extends ClipVideoBaseParams {
downloaderExecutablePath?: string;
postprocessorExecutablePath?: string;
}

interface ClipVideoParams extends ClipYouTubeVideoParams {
@@ -38,6 +38,22 @@ const convertDurationToSeconds = (d: string) => {
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 {
process(): void;
}
@@ -47,33 +63,96 @@ class YouTubeVideoClipEventEmitter extends EventEmitter implements ClipEventEmit
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 = [
'--youtube-skip-dash-manifest',
'-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', {
type: 'download',
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) {
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', {
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;
}

@@ -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 = [
'-ss', (this.params.start ?? '00:00:00').toString(),
'-i', videoUrlStream,
@@ -124,7 +195,7 @@ class YouTubeVideoClipEventEmitter extends EventEmitter implements ClipEventEmit
ffmpegProcessAudioStreamArgs.push(clipDuration);
}

const cacheFilename = 'output.webm'; // todo label this on the cache
const cacheFilename = getCacheFilename(); // todo label this on the cache
const ffmpegProcessArgs = [
...ffmpegProcessVideoStreamArgs,
...ffmpegProcessAudioStreamArgs,
@@ -135,23 +206,42 @@ class YouTubeVideoClipEventEmitter extends EventEmitter implements ClipEventEmit
this.emit('process', {
type: 'postprocess',
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) {
this.emit('error', new Error('ffmpeg error.', { cause: ffmpegProcess.error }));
this.emit('end');
return;
}
const output = readFileSync(cacheFilename);
unlinkSync(cacheFilename);
this.emit('process', {
type: 'postprocess',
phase: 'success',
});
const output = readFileSync(cacheFilename);
unlinkSync(cacheFilename);
this.emit('success', {
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: ClipVideoParams) => {


+ 141
- 76
test/index.test.ts Переглянути файл

@@ -20,87 +20,152 @@ describe('createVideoClipper', () => {
(readFileSync as Mock).mockReturnValueOnce(Buffer.from(''));
});

let clipper: ClipEventEmitter;
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,
describe('with postprocessing', () => {
let clipper: ClipEventEmitter;
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);
afterEach(() => {
(spawnSync as Mock).mockReset();
});
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 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();
}));
});

it('calls the cleanup function', () => new Promise((done) => {
(spawnSync as Mock)
.mockReturnValueOnce({ stdout: Buffer.from('') })
.mockReturnValueOnce({});
describe('without postprocessing', () => {
let clipper: ClipEventEmitter;
beforeEach(() => {
clipper = webVideoClipCore.createVideoClipper({
downloaderExecutablePath: 'yt-dlp',
type: webVideoClipCore.VideoType.YOUTUBE,
url: 'https://www.youtube.com/watch?v=BaW_jenozKc',
start: 0,
end: 0,
});
});

clipper.on('end', done);
clipper.process();
afterEach(() => {
(spawnSync as Mock).mockReset();
});

expect(unlinkSync).toBeCalled();
}));
it('calls the downloader function', () => new Promise((done) => {
(spawnSync as Mock)
.mockReturnValueOnce({ stdout: Buffer.from('') })
.mockReturnValueOnce({});

clipper.on('end', done);
clipper.process();
expect(spawnSync).toBeCalledTimes(1);
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 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();
}));
});
});

Завантаження…
Відмінити
Зберегти