120 Вводное видео
С помощью FFmpeg будет реализована утилита для конвертирования видео в нужный формат
121 Обзор проекта
Основная проблема: чтобы написать конфиг для рендера видео в нужный нам формат, нужно потратить крайне много времени и сил
Поэтому хочется сделать простой интерфейс, в который мы зададим только основные параметры и получим на выходе нужное нам видео
И так же в самом перспективном варианте нужно будет сделать так, чтобы приложение под капотом выполняло и другие задачи пользователя по выбору (от других инструментов)
122 Наивная реализация
Первым делом нужно добавить библиотеку, которая позволит реализовать вывод нужных нам вопросов в консоль и получить эти данные
npm i inquirer
npm i @types/inquirer
И вот представлен пример реализации программы в 43 строчки кода. Это функция, которая принимает в себя в первой части ввод пользовательских данных, во второй выполняет саму внутреннюю логику, а в третьей выдаёт логи о результатах выполнения.
Однако нужно ещё и сказать про большое количество неудобств, вызыванных такой быстрой реализацией:
- У нас ограниченный набор настроек . В
res
мы задали их только под один конкретный случай - выбор разрешения у видео (без возможностей добавить то же сжатие) - Разделение зависимостей прямо внутри программы. У нас она состоит из получения данных от пользователя, обработки этих данных и логов
- Изменение этих зависимостей. Если нам нужно изменить или дополнить логику для них (например, добавить уведомление в телеграм)
import { spawn } from 'child_process';
import inquirer from 'inquirer';
(async function convert() {
// ! Первая часть - пользовательский ввод
// Спросит про ввод ширины
const { result: width } = await inquirer.prompt([{
type: 'number',
name: 'result',
message: 'Ширина'
}]);
// Спросит про ввод высоты
const { result: height } = await inquirer.prompt([{
type: 'number',
name: 'result',
message: 'Высота'
}]);
// Вводим путь
const { result: path } = await inquirer.prompt([{
type: 'input',
name: 'result',
message: 'Путь'
}]);
// Спросит про название выходного файла
const { result: name } = await inquirer.prompt([{
type: 'input',
name: 'result',
message: 'Название'
}]);
// ! Вторая часть - логика программы
// Генерация самой команды для ffmpeg
// Spawn - нод-утилита для генерации процесса
const res = spawn('ffmpeg', [
'-i', path,
'-c:v', 'libx264',
'-s', `${width}x${height}`,
path + name + '.mp4'
]);
// ! Третья часть - обработка параметров
// Получаем чанк данных и выводим их в консоль
res.stdout.on('data', (data: any) => {
console.log(data.toString());
});
// Выводит сообщение об ошибке
res.stderr.on('data', (data: any) => {
console.log(data.toString());
});
// Выводит сообщение об окончании процесса
res.on('close', () => {
console.log('Готово');
});
}());
Однако, если посмотреть на эту программу с точки зрения микросервиса, то нам будет просто необходимым добавление абстракций, потому что расширять проект будет просто невозможно без них
123 Старт приложения
Если в голову сразу не приходит архитектура приложения, то сначала можно написать наивную реализацию этого приложения
Будем использовать Template Method для реализации однотипных задач - работа с Ansible, FFmpeg и т.д. Построение команды - builder
И на изображении показана структура проекта и его разделения. Всё будет собираться под одной оболочкой - App.
Создаём проект
mkdir command-executer // создаём папку
cd command-executer // переходим в неё
npm init // инициализируем проект в ноде
npm i -g typescript // устанавливаем ТС
tsc --init // инициализируем ТС
git init // Инициализируем гит
Добавляем гитигнор
/build
/node_modules
Настраиваем пути компиляции
//tsconfig
"rootDir": "./src",
"outDir": "./dist",
Устанавливаем пакет для работы с запросами
npm i inquirer
npm i -D @types/inquirer
Примерно так выглядит пакет
124 Обработка ввода
Примерно так выглядит проект на данном этапе реализации программы
И вот внутренний код
// prompt.types.ts
// Тут мы храним типы, которые будут использоваться в наших сервисах
export type PromptType = 'input' | 'number' | 'password';
// prompt.service.ts
// Тут мы будем реализовывать логику сервиса ввода данных
import inquirer from 'inquirer';
import { PromptType } from "./prompt.types";
export class PromptService {
// Инпут делаем дженериком, чтобы мы могли точно сразу определить тип того значения, которое мы передаём в данную функцию
public async input<T>(message: string, type: PromptType) {
const { result } = await inquirer.prompt<{ result: T }>([
{
type,
name: 'result',
message
}
]);
return result;
}
}
// App.ts
// Конкретно тут мы собираем всю нашу логику приложения и запускаем его
import { PromptService } from "./core/prompt/prompt.service";
export class App {
async run() {
const result = await (new PromptService()).input<number>('Число', 'number');
console.log(result);
}
}
const app = new App();
app.run();
125 Обработка вывода
Нам нужны типы node.js
npm i -D @types/node
Код самого ввода:
// stream-logger.interface.ts
// Хранит интерфейс логгера потоков
export interface IStreamLogger {
log(...args: any[]): void;
error(...args: any[]): void;
end(): void;
}
// stream.handler.ts
// Реализация ввода данных через node.js
import { IStreamLogger } from "./stream-logger.interface";
import {ChildProcessWithoutNullStreams} from "child_process";
export class StreamHandler {
constructor(private logger: IStreamLogger) { }
// Конкретно тут поток имеет тип из ноды, а именно поток без отсутствующих значений
processOutput(stream: ChildProcessWithoutNullStreams) {
stream.stdout.on('data', (data: any) => {
this.logger.log(data);
})
stream.stdout.on('data', (data: any) => {
this.logger.error(data);
})
stream.on('close', () => {
this.logger.end();
});
}
}
126 Упражнение - Консольный вывод
Реализация консольного вывода в приложении. Тут так же можно дорасширить его, добавив вывод в телеграмме
import { IStreamLogger } from "../../core/handlers/stream-logger.interface";
// Реализация вывода в приложении - реализован через синглтон
export class ConsoleLogger implements IStreamLogger {
// Хранит инстанс класса
private static logger: ConsoleLogger;
// Возвращает инстанс класса
public static getInstance(): ConsoleLogger {
// Если его нет, то создаёт
if (!ConsoleLogger.logger) {
ConsoleLogger.logger = new ConsoleLogger();
} // Если есть, то вернёт имеющийся
return ConsoleLogger.logger;
}
// Выводы результатов запросов:
end(): void {
console.log('Готово');
}
error(...args: any[]): void {
console.log(...args);
}
log(...args: any[]): void {
console.log(...args);
}
}
127 Упражнение - Шаблонный метод исполнителя
Нам нужно реализовать абстрактный Command Executor, который последовательно должен представлять выполнение четырёх действий: ввод, построение команды, запуск потока, обработка потока
// command.types.ts
// Интерфейс комманд
export interface ICommandExec {
command: string;
args: string[];
}
// command.executer.ts
import {IStreamLogger} from "../handlers/stream-logger.interface";
import {ChildProcessWithoutNullStreams} from "child_process";
import {ICommandExec} from "./command.types";
export abstract class CommandExecutor<Input> {
constructor(private logger: IStreamLogger) {
}
public async execute() {
const input = await this.prompt();
const command = this.build(input);
const stream = this.spawn(command);
this.processStream(stream, this.logger);
}
protected abstract prompt(): Promise<Input>;
protected abstract build(input: Input): ICommandExec;
protected abstract spawn(command: ICommandExec): ChildProcessWithoutNullStreams;
protected abstract processStream(stream: ChildProcessWithoutNullStreams, logger: IStreamLogger): void;
}
128 Упражнение - Builder для ffmpeg
Конкретно тут мы должны будем реализовать команду, которая будет содержать финализированный набор наших опций для отдельного элемента - ffmpeg-рендера
Конкретно нам нужно построить команду, которая будет хранить статичное значение и вводимые нами данные
В конфиге тайпскрипта нам нужно отключить необходимость инициализировать значения через конструктор (для использования паттерна билдер)
// typescript.config
"strictPropertyInitialization": false,
Этот класс будет отвечать за создание билдера запроса в ffmpeg на рендер видео
// ffmpeg.builder.ts
export class FfmpegBuilder {
private inputPath: string;
private options: Map<string, string> = new Map();
constructor() {
this.options.set('-c:v', 'libx264');
}
input(inputPath: string): this {
this.inputPath = inputPath;
return this;
}
setVideoSize(width: number, height: number): this {
this.options.set('-s', `${1920}x${1080}`);
return this;
}
output(outputPath: string): string[] {
if (!this.inputPath) {
throw new Error('Не задан input');
} const args: string[] = ['-i', this.inputPath];
this.options.forEach((value, key) => {
args.push(key);
args.push(value);
});
args.push(outputPath);
return args;
}
}
И в теории можно вызвать этот код так:
new FfmpegBuilder()
.input('')
.setVideoSize(1920, 1080)
.output('///');
129 Работа с файлами
Дальше нам нужно реализовать код, который будет выполнять сбор путей, их билд и спавн. Дальше мы сможем окончательно дореализовать ffmpeg
// file.service.ts
import { join, dirname, isAbsolute } from "path";
import {promises} from "fs";
export class FileService {
private async isExist(path: string): Promise<boolean> {
try{
await promises.stat(path);
return true;
} catch {
return false;
}
}
public getFilePath(path: string, name: string, extension: string): string {
if (!isAbsolute(path)) {
path = join(__dirname + '/' + path);
} return join(dirname(path) + '/' + name + '.' + extension);
}
async deleteFileIfExists(path: string, name: string) {
if (await this.isExist(path)) {
promises.unlink(path);
}
}
}
130 Упражнение - Ffmpeg executor
И дальше нам нужно реализовать executer, который будет исполнять наши логи
// ffmpeg-types.ts
import {ICommandExec} from "../../core/executor/command.types";
export interface IFfmpegInput {
width: number;
height: number;
path: string;
name: string;
}
export interface ICommandExecFfmpeg extends ICommandExec {
output: string;
}
// ffmpeg.executer.ts
import {CommandExecutor} from "../../core/executor/command.executor";
import {IFfmpegInput, ICommandExecFfmpeg} from "./ffmpeg-types";
import {IStreamLogger} from "../../core/handlers/stream-logger.interface";
import {FileService} from "../../core/files/file.service";
import {PromptService} from "../../core/prompt/prompt.service";
import {ChildProcessWithoutNullStreams, spawn} from "child_process";
import {FfmpegBuilder} from "./ffmpeg.builder";
import {StreamHandler} from "../../core/handlers/stream.handler";
export class FfmpegExecutor extends CommandExecutor<IFfmpegInput> {
private fileService: FileService = new FileService();
private promptService: PromptService = new PromptService();
constructor(logger: IStreamLogger) {
super(logger);
}
// Вызов ввода
protected async prompt(): Promise<any> {
const width = await this.promptService.input<number>('Ширина', 'number');
const height = await this.promptService.input<number>('Высота', 'number');
const path = await this.promptService.input<string>('Путь до файла', 'input');
const name = await this.promptService.input<string>('Имя файла', 'input');
return {width, height, path, name};
}
protected build({ width, height, path, name }: IFfmpegInput): ICommandExecFfmpeg {
const output = this.fileService.getFilePath(path, name, 'mp4');
const args = (new FfmpegBuilder())
.input(path)
.setVideoSize(width, height)
.output(output);
return { command: 'ffmpeg', args, output }
}
protected spawn({ output, command, args}: ICommandExecFfmpeg): ChildProcessWithoutNullStreams {
this.fileService.deleteFileIfExists(output);
return spawn(command, args);
}
protected processStream(stream: ChildProcessWithoutNullStreams, logger: IStreamLogger): void {
const handler = new StreamHandler(logger);
handler.processOutput(stream);
}
}
131 Финал проекта
Вот так в конечном итоге выглядит архитектура проекта:
Это структура нашего проекта:
- Все вызываемые команды (ffmpeg, dir) находятся в commands
- Вся основная реализация проекта (идентичная для всех команд) находится в папке core
- В out располагается инструмент вывода у программы
- Через App.ts у нас идёт запуск проекта (App.js нужно врубать в консоли)
Вот так выглядит App.ts, через который вызывается весь наш проект:
// App.ts
import {FfmpegExecutor} from "./commands/ffmpeg/ffmpeg.executor";
import {ConsoleLogger} from "./out/console-logger/console-logger";
export class App {
async run() {
await new FfmpegExecutor(ConsoleLogger.getInstance()).execute();
}
}
const app = new App();
app.run();
Так же очень легко можно дописать дополнительную реализацию Dir-утилиты
// dir.types.ts
export interface DirInput {
path: string;
}
// dir.builder.ts
export class DirBuilder {
private options: Map<string, string> = new Map();
detailOutput() {
this.options.set('-l', '');
return this;
}
output(): string[] {
const args: string[] = [];
this.options.forEach((value, key) => {
args.push(key);
args.push(value);
});
return args;
}
}
// dir.executor.ts
import {CommandExecutor} from "../../core/executor/command.executor";
import {DirInput} from "./dir.types";
import {PromptService} from "../../core/prompt/prompt.service";
import {IStreamLogger} from "../../core/handlers/stream-logger.interface";
import {ICommandExec} from "../../core/executor/command.types";
import {ChildProcessWithoutNullStreams, spawn} from "child_process";
import {DirBuilder} from "./dir.builder";
import {StreamHandler} from "../../core/handlers/stream.handler";
export class DirExecutor extends CommandExecutor<DirInput> {
private promptService: PromptService = new PromptService();
constructor(logger: IStreamLogger) {
super(logger);
}
protected build({path}: DirInput): ICommandExec {
const args = (new DirBuilder()).detailOutput().output();
return { command: 'ls', args: args.concat(path) };
}
protected processStream(stream: ChildProcessWithoutNullStreams, output: IStreamLogger): void {
const handler = new StreamHandler(output);
handler.processOutput(stream);
}
protected async prompt(): Promise<DirInput> {
let path = await this.promptService.input<string>('Путь', 'input');
return { path };
}
protected spawn({command: command, args}: ICommandExec): ChildProcessWithoutNullStreams {
return spawn(command, args);
}
}
Вот так выглядит изменённый App.ts, в котором находится вызов теперь не FFmpeg, а Dir-утилиты
// App.ts
import {ConsoleLogger} from "./out/console-logger/console-logger";
import {DirExecutor} from "./commands/dir/dir.executor";
export class App {
async run() {
// Либо мы можем подставить реализацию другой логики при первой необходимости
await new DirExecutor(ConsoleLogger.getInstance()).execute();
}
}
const app = new App();
app.run();
И вот так коротко выглядят эти различные команды: