From 80acb13912017a77c9fb49190be28250e042335c Mon Sep 17 00:00:00 2001 From: SrGooglo Date: Thu, 24 Apr 2025 06:08:32 +0000 Subject: [PATCH] Refactor SegmentedAudioMPDJob to extend FFMPEGLib --- packages/server/classes/FFMPEGLib/index.js | 110 +++++++++++ .../classes/SegmentedAudioMPDJob/index.js | 182 +++++++++--------- 2 files changed, 199 insertions(+), 93 deletions(-) create mode 100644 packages/server/classes/FFMPEGLib/index.js diff --git a/packages/server/classes/FFMPEGLib/index.js b/packages/server/classes/FFMPEGLib/index.js new file mode 100644 index 00000000..f0b67b72 --- /dev/null +++ b/packages/server/classes/FFMPEGLib/index.js @@ -0,0 +1,110 @@ +import { EventEmitter } from "node:events" +import child_process from "node:child_process" + +function getBinaryPath(name) { + try { + return child_process + .execSync(`which ${name}`, { encoding: "utf8" }) + .trim() + } catch (error) { + return null + } +} + +export class FFMPEGLib extends EventEmitter { + constructor() { + super() + this.ffmpegBin = getBinaryPath("ffmpeg") + this.ffprobeBin = getBinaryPath("ffprobe") + } + + handleProgress(stdout, endTime, onProgress = () => {}) { + let currentTime = 0 + + stdout.on("data", (data) => { + for (const line of data.toString().split("\n")) { + if (line.startsWith("out_time_ms=")) { + currentTime = parseInt(line.split("=")[1]) / 1000000 + } else if (line.startsWith("progress=")) { + const status = line.split("=")[1] + + if (status === "end") { + onProgress(100) + } else if (endTime > 0 && currentTime > 0) { + onProgress( + Math.min( + 100, + Math.round((currentTime / endTime) * 100), + ), + ) + } + } + } + }) + } + + ffmpeg(payload) { + return this.exec(this.ffmpegBin, payload) + } + + ffprobe(payload) { + return this.exec(this.ffprobeBin, payload) + } + + exec(bin, { args, onProcess, cwd }) { + if (Array.isArray(args)) { + args = args.join(" ") + } + + return new Promise((resolve, reject) => { + const process = child_process.exec( + `${bin} ${args}`, + { + cwd: cwd, + }, + (error, stdout, stderr) => { + if (error) { + reject(stderr) + } else { + resolve(stdout.toString()) + } + }, + ) + + if (typeof onProcess === "function") { + onProcess(process) + } + }) + } +} + +export class Utils { + static async probe(input) { + const lib = new FFMPEGLib() + + const result = await lib + .ffprobe({ + args: [ + "-v", + "error", + "-print_format", + "json", + "-show_format", + "-show_streams", + input, + ], + }) + .catch((err) => { + console.log(err) + return null + }) + + if (!result) { + return null + } + + return JSON.parse(result) + } +} + +export default FFMPEGLib diff --git a/packages/server/classes/SegmentedAudioMPDJob/index.js b/packages/server/classes/SegmentedAudioMPDJob/index.js index 55b8cdc9..365660ca 100644 --- a/packages/server/classes/SegmentedAudioMPDJob/index.js +++ b/packages/server/classes/SegmentedAudioMPDJob/index.js @@ -1,112 +1,108 @@ import fs from "node:fs" import path from "node:path" -import { exec } from "node:child_process" -import { EventEmitter } from "node:events" -export default class SegmentedAudioMPDJob { - constructor({ - input, - outputDir, - outputMasterName = "master.mpd", +import { FFMPEGLib, Utils } from "../FFMPEGLib" - audioCodec = "aac", - audioBitrate = undefined, - audioSampleRate = undefined, - segmentTime = 10, - }) { - this.input = input - this.outputDir = outputDir - this.outputMasterName = outputMasterName +export default class SegmentedAudioMPDJob extends FFMPEGLib { + constructor(params = {}) { + super() - this.audioCodec = audioCodec - this.audioBitrate = audioBitrate - this.segmentTime = segmentTime - this.audioSampleRate = audioSampleRate + this.params = { + outputMasterName: "master.mpd", + audioCodec: "libopus", + audioBitrate: "320k", + audioSampleRate: "48000", + segmentTime: 10, + includeMetadata: true, + ...params, + } + } - this.bin = require("ffmpeg-static") + buildSegmentationArgs = () => { + const args = [ + //`-threads 1`, // limits to one thread + `-v error -hide_banner -progress pipe:1`, + `-i ${this.params.input}`, + `-c:a ${this.params.audioCodec}`, + `-map 0:a`, + `-f dash`, + `-dash_segment_type mp4`, + `-segment_time ${this.params.segmentTime}`, + `-use_template 1`, + `-use_timeline 1`, + `-init_seg_name "init.m4s"`, + ] - return this - } + if (this.params.includeMetadata === false) { + args.push(`-map_metadata -1`) + } - events = new EventEmitter() + if ( + typeof this.params.audioBitrate === "string" && + this.params.audioBitrate !== "default" + ) { + args.push(`-b:a ${this.params.audioBitrate}`) + } - buildCommand = () => { - const cmdStr = [ - this.bin, - `-v quiet -stats`, - `-i ${this.input}`, - `-c:a ${this.audioCodec}`, - `-map 0:a`, - `-map_metadata -1`, - `-f dash`, - `-dash_segment_type mp4`, - `-segment_time ${this.segmentTime}`, - `-use_template 1`, - `-use_timeline 1`, - `-init_seg_name "init.m4s"`, - ] + if ( + typeof this.params.audioSampleRate !== "undefined" && + this.params.audioSampleRate !== "default" + ) { + args.push(`-ar ${this.params.audioSampleRate}`) + } - if (typeof this.audioBitrate !== "undefined") { - cmdStr.push(`-b:a ${this.audioBitrate}`) - } + args.push(this.params.outputMasterName) - if (typeof this.audioSampleRate !== "undefined") { - cmdStr.push(`-ar ${this.audioSampleRate}`) - } + return args + } - cmdStr.push(this.outputMasterName) + run = async () => { + const segmentationCmd = this.buildSegmentationArgs() + const outputPath = + this.params.outputDir ?? `${path.dirname(this.params.input)}/dash` + const outputFile = path.join(outputPath, this.params.outputMasterName) - return cmdStr.join(" ") - } + this.emit("start", { + input: this.params.input, + output: outputPath, + params: this.params, + }) - run = () => { - const cmdStr = this.buildCommand() + if (!fs.existsSync(outputPath)) { + fs.mkdirSync(outputPath, { recursive: true }) + } - console.log(cmdStr) + const inputProbe = await Utils.probe(this.params.input) - const cwd = `${path.dirname(this.input)}/dash` + try { + const result = await this.ffmpeg({ + args: segmentationCmd, + onProcess: (process) => { + this.handleProgress( + process.stdout, + parseFloat(inputProbe.format.duration), + (progress) => { + this.emit("progress", progress) + }, + ) + }, + cwd: outputPath, + }) - if (!fs.existsSync(cwd)) { - fs.mkdirSync(cwd, { recursive: true }) - } + let outputProbe = await Utils.probe(outputFile) - console.log(`[DASH] Started audio segmentation`, { - input: this.input, - cwd: cwd, - }) + this.emit("end", { + probe: { + input: inputProbe, + output: outputProbe, + }, + outputPath: outputPath, + outputFile: outputFile, + }) - const process = exec( - cmdStr, - { - cwd: cwd, - }, - (error, stdout, stderr) => { - if (error) { - console.log(`[DASH] Failed to segment audio >`, error) - - return this.events.emit("error", error) - } - - if (stderr) { - //return this.events.emit("error", stderr) - } - - console.log(`[DASH] Finished segmenting audio >`, cwd) - - return this.events.emit("end", { - filepath: path.join(cwd, this.outputMasterName), - isDirectory: true, - }) - } - ) - - process.stdout.on("data", (data) => { - console.log(data.toString()) - }) - } - - on = (key, cb) => { - this.events.on(key, cb) - return this - } -} \ No newline at end of file + return result + } catch (err) { + return this.emit("error", err) + } + } +}