Browse Source

Update code

Use --force-keyframes-at-cuts to have a smooth cut.
master
TheoryOfNekomata 1 year ago
parent
commit
86963b6b53
2 changed files with 261 additions and 106 deletions
  1. +120
    -30
      src/index.ts
  2. +141
    -76
      test/index.test.ts

+ 120
- 30
src/index.ts View File

@@ -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) => {


+ 141
- 76
test/index.test.ts View File

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

Loading…
Cancel
Save