From e764e0dca7014546611211fbe027c1fea789b82f Mon Sep 17 00:00:00 2001 From: Nikita Date: Sun, 2 Aug 2026 17:58:24 +0200 Subject: [PATCH 1/2] chore: rewrite to TS again --- .gitignore | 3 +- lib/index.js | 861 +++++++++++-------------- lib/index.ts | 467 ++++++++++++++ tests/{index.test.js => index.test.ts} | 2 +- tsconfig.build.json | 3 +- tsconfig.json | 7 +- 6 files changed, 850 insertions(+), 493 deletions(-) create mode 100644 lib/index.ts rename tests/{index.test.js => index.test.ts} (98%) diff --git a/.gitignore b/.gitignore index a5d2b57..66d8f01 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /node_modules/ -/lib/*.d.ts +*.d.ts +*.js lcov.info diff --git a/lib/index.js b/lib/index.js index 993708c..8acf869 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,15 +1,13 @@ -import debug from "debug" -import assert from "node:assert" -import { spawn } from "node:child_process" -import { createReadStream, createWriteStream } from "node:fs" -import { unlink } from "node:fs/promises" -import { tmpdir } from "node:os" -import { join } from "node:path" -import { PassThrough } from "node:stream" - -const dbg = debug("ffmpeg-stream") -const EXIT_CODES = [0, 255] - +import debug from "debug"; +import assert from "node:assert"; +import { spawn } from "node:child_process"; +import { createReadStream, createWriteStream } from "node:fs"; +import { unlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { PassThrough } from "node:stream"; +const dbg = debug("ffmpeg-stream"); +const EXIT_CODES = [0, 255]; /** * A class which wraps a FFmpeg process. * @@ -27,496 +25,387 @@ const EXIT_CODES = [0, 255] * ``` */ export class Converter { - /** - * @private - */ - fdCount = 0 - - /** - * @private - * @readonly - * @type {ConverterPipe[]} - */ - pipes = [] - - /** - * @private - * @type {import("node:child_process").ChildProcess | undefined} - */ - process - - /** - * @private - */ - killed = false - - /** - * Initializes the converter. - * - * Remember to call {@link Converter.run} to actually start the FFmpeg process. - * - * @param {string} [ffmpegPath] Path to the FFmpeg executable. (default: `"ffmpeg"`) - */ - constructor(ffmpegPath = "ffmpeg") { - /** @private */ - this.ffmpegPath = ffmpegPath - } - - /** - * Defines an FFmpeg input file. - * - * This builds a command like the one you would normally use in the terminal. - * - * @param {string} file Path to the input file. - * @param {ConverterPipeOptions} [options] FFmpeg options for this input. - * - * @example - * - * ```js - * import { Converter } from "ffmpeg-stream" - * - * const converter = new Converter() - * - * converter.createInputFromFile("input.mp4", { r: 30 }) - * // ffmpeg -r 30 -i input.mp4 ... - * - * await converter.run() - * ``` - */ - createInputFromFile(file, options = {}) { - this.pipes.push({ - type: "input", - options, - file, - }) - } - - /** - * Defines an FFmpeg output file. - * - * This builds a command like the one you would normally use in the terminal. - * - * @param {string} file Path to the output file. - * @param {ConverterPipeOptions} [options] FFmpeg options for this output. - * - * @example - * - * ```js - * import { Converter } from "ffmpeg-stream" - * - * const converter = new Converter() - * - * converter.createOutputToFile("output.mp4", { vcodec: "libx264" }) - * // ffmpeg ... -vcodec libx264 output.mp4 - * - * await converter.run() - * ``` - */ - createOutputToFile(file, options = {}) { - this.pipes.push({ - type: "output", - options, - file, - }) - } - - /** - * Defines an FFmpeg input stream. - * - * Internally, it adds a special `pipe:` input argument to the FFmpeg command. - * - * Remember to specify the [`f` option](https://ffmpeg.org/ffmpeg.html#Main-options), - * which specifies the format of the input data. - * - * @param {ConverterPipeOptions} options FFmpeg options for this input. - * @returns {import("node:stream").Writable} A stream which will be written to the FFmpeg process' stdio. - * - * @example - * - * ```js - * import { createReadStream } from "node:fs" - * import { Converter } from "ffmpeg-stream" - * - * const converter = new Converter() - * - * createReadStream("input.mp4").pipe( - * converter.createInputStream({ f: "mp4" }), - * ) - * - * await converter.run() - * ``` - */ - createInputStream(options) { - const stream = new PassThrough() - const fd = this.getUniqueFd() - this.pipes.push({ - type: "input", - options, - file: `pipe:${fd}`, - onSpawn: process => { - const stdio = process.stdio[fd] - if (stdio == null) throw Error(`input ${fd} is null`) - debugStream(stream, `input ${fd}`) - if (!("write" in stdio)) throw Error(`input ${fd} is not writable`) - stream.pipe(stdio) - }, - }) - - return stream - } - - /** - * Defines an FFmpeg output stream. - * - * Internally, it adds a special `pipe:` output argument to the FFmpeg command. - * - * Remember to specify the [`f` option](https://ffmpeg.org/ffmpeg.html#Main-options), - * which specifies the format of the output data. - * - * @param {ConverterPipeOptions} options FFmpeg options for this output. - * @returns {import("node:stream").Readable} A stream which will be read from the FFmpeg process' stdio. - * - * @example - * - * ```js - * import { createWriteStream } from "node:fs" - * import { Converter } from "ffmpeg-stream" - * - * const converter = new Converter() - * - * converter.createOutputStream({ f: "mp4" }) - * .pipe(createWriteStream("output.mp4")) - * - * await converter.run() - * ``` - */ - createOutputStream(options) { - const stream = new PassThrough() - const fd = this.getUniqueFd() - this.pipes.push({ - type: "output", - options, - file: `pipe:${fd}`, - onSpawn: process => { - const stdio = process.stdio[fd] - if (stdio == null) throw Error(`output ${fd} is null`) - debugStream(stdio, `output ${fd}`) - stdio.pipe(stream) - }, - }) - return stream - } - - /** - * Defines an FFmpeg input stream from a temporary file. - * - * Creates a temporary file that you can write to and instructs FFmpeg to read from it. - * Note that the actual conversion process will not start until the file is fully written. - * - * Use this method if the format you want to read doesn't support non-seekable input. - * - * @param {ConverterPipeOptions} options FFmpeg options for this input. - * @returns {import("node:stream").Writable} A stream which will be written to the temporary file. - */ - createBufferedInputStream(options) { - const stream = new PassThrough() - const file = getTmpPath("ffmpeg-") - this.pipes.push({ - type: "input", - options, - file, - onBegin: async () => { - await new /** @type {typeof Promise} */ (Promise)((resolve, reject) => { - const writer = createWriteStream(file) - stream.pipe(writer) - stream.on("end", () => { - dbg("input buffered stream end") - resolve() - }) - stream.on("error", err => { - dbg(`input buffered stream error: ${err.message}`) - reject(err) - }) - }) - }, - onFinish: async () => { - await unlink(file) - }, - }) - return stream - } - - /** - * Defines an FFmpeg output stream to a temporary file. - * - * Creates a temporary file that you can read from and instructs FFmpeg to write to it. - * Note that you will be able to read from the file only after the conversion process is finished. - * - * Use this method if the format you want to write doesn't support non-seekable output. - * - * @param {ConverterPipeOptions} options FFmpeg options for this output. - * @returns {import("node:stream").Readable} A stream which will be read from the temporary file. - */ - createBufferedOutputStream(options) { - const stream = new PassThrough() - const file = getTmpPath("ffmpeg-") - this.pipes.push({ - type: "output", - options, - file, - onFinish: async () => { - await new /** @type {typeof Promise} */ (Promise)((resolve, reject) => { - const reader = createReadStream(file) - reader.pipe(stream) - reader.on("end", () => { - dbg("output buffered stream end") - resolve() - }) - reader.on("error", err => { - dbg(`output buffered stream error: ${err.message}`) - reject(err) - }) - }) - await unlink(file) - }, - }) - return stream - } - - /** - * Starts the conversion process. - * - * You can use {@link Converter.kill} to cancel the conversion. - * - * @returns {Promise} Promise which resolves on normal exit or kill, but rejects on ffmpeg error. - */ - async run() { - /** @type {ConverterPipe[]} */ - const pipes = [] - try { - for (const pipe of this.pipes) { - dbg(`prepare ${pipe.type}`) - await pipe.onBegin?.() - pipes.push(pipe) - } - - const args = this.getSpawnArgs() - const stdio = this.getStdioArg() - dbg(`spawn: ${this.ffmpegPath} ${args.join(" ")}`) - dbg(`spawn stdio: ${stdio.join(" ")}`) - this.process = spawn(this.ffmpegPath, args, { stdio }) - const finished = this.handleProcess() - - for (const pipe of this.pipes) { - pipe.onSpawn?.(this.process) - } - - if (this.killed) { - // the converter was already killed so stop it immediately - this.process.kill() - } - - await finished - } finally { - for (const pipe of pipes) { - await pipe.onFinish?.() - } + #ffmpegPath; + #fdCount = 0; + #pipes = []; + #process; + #killed = false; + /** + * Initializes the converter. + * + * Remember to call {@link Converter.run} to actually start the FFmpeg process. + * + * @param ffmpegPath Path to the FFmpeg executable. (default: `"ffmpeg"`) + */ + constructor(ffmpegPath = "ffmpeg") { + this.#ffmpegPath = ffmpegPath; } - } - - /** - * Stops the conversion process. - */ - kill() { - // kill the process if it already started - this.process?.kill() - // set the flag so it will be killed after it's initialized - this.killed = true - } - - /** - * @private - * @returns {number} - */ - getUniqueFd() { - return this.fdCount++ + 3 - } - - /** - * Returns stdio pipes which can be passed to {@link spawn}. - * @private - * @returns {Array<"ignore" | "pipe">} - */ - getStdioArg() { - return [ - "ignore", - "ignore", - "pipe", - .../** @type {typeof Array<"pipe">} */ (Array)(this.fdCount).fill("pipe"), - ] - } - - /** - * Returns arguments which can be passed to {@link spawn}. - * @private - * @returns {string[]} - */ - getSpawnArgs() { - /** @type {string[]} */ - const args = [] - - for (const pipe of this.pipes) { - if (pipe.type !== "input") continue - args.push(...stringifyArgs(pipe.options)) - args.push("-i", pipe.file) + /** + * Defines an FFmpeg input file. + * + * This builds a command like the one you would normally use in the terminal. + * + * @param file Path to the input file. + * @param options FFmpeg options for this input. + * + * @example + * + * ```js + * import { Converter } from "ffmpeg-stream" + * + * const converter = new Converter() + * + * converter.createInputFromFile("input.mp4", { r: 30 }) + * // ffmpeg -r 30 -i input.mp4 ... + * + * await converter.run() + * ``` + */ + createInputFromFile(file, options = {}) { + this.#pipes.push({ + type: "input", + options, + file, + }); } - for (const pipe of this.pipes) { - if (pipe.type !== "output") continue - args.push(...stringifyArgs(pipe.options)) - args.push(pipe.file) + /** + * Defines an FFmpeg output file. + * + * This builds a command like the one you would normally use in the terminal. + * + * @param file Path to the output file. + * @param options FFmpeg options for this output. + * + * @example + * + * ```js + * import { Converter } from "ffmpeg-stream" + * + * const converter = new Converter() + * + * converter.createOutputToFile("output.mp4", { vcodec: "libx264" }) + * // ffmpeg ... -vcodec libx264 output.mp4 + * + * await converter.run() + * ``` + */ + createOutputToFile(file, options = {}) { + this.#pipes.push({ + type: "output", + options, + file, + }); } - - return args - } - - /** - * @private - */ - async handleProcess() { - await new /** @type {typeof Promise} */ (Promise)((resolve, reject) => { - let logSectionNum = 0 - /** @type {string[]} */ - const logLines = [] - - assert(this.process != null, "process should be initialized") - - if (this.process.stderr != null) { - this.process.stderr.setEncoding("utf8") - - this.process.stderr.on( - "data", - /** @type {(data: string) => void} */ data => { - const lines = data.split(/\r\n|\r|\n/u) - for (const line of lines) { - // skip empty lines - if (/^\s*$/u.exec(line) != null) continue - // if not indented: increment section counter - if (/^\s/u.exec(line) == null) logSectionNum++ - // only log sections following the first one - if (logSectionNum > 1) { - dbg(`log: ${line}`) - logLines.push(line) - } + /** + * Defines an FFmpeg input stream. + * + * Internally, it adds a special `pipe:` input argument to the FFmpeg command. + * + * Remember to specify the [`f` option](https://ffmpeg.org/ffmpeg.html#Main-options), + * which specifies the format of the input data. + * + * @param options FFmpeg options for this input. + * @returns A stream which will be written to the FFmpeg process' stdio. + * + * @example + * + * ```js + * import { createReadStream } from "node:fs" + * import { Converter } from "ffmpeg-stream" + * + * const converter = new Converter() + * + * createReadStream("input.mp4").pipe( + * converter.createInputStream({ f: "mp4" }), + * ) + * + * await converter.run() + * ``` + */ + createInputStream(options) { + const stream = new PassThrough(); + const fd = this.getUniqueFd(); + this.#pipes.push({ + type: "input", + options, + file: `pipe:${fd}`, + onSpawn: process => { + const stdio = process.stdio[fd]; + if (stdio == null) + throw Error(`input ${fd} is null`); + debugStream(stream, `input ${fd}`); + if (!("write" in stdio)) + throw Error(`input ${fd} is not writable`); + stream.pipe(stdio); + }, + }); + return stream; + } + /** + * Defines an FFmpeg output stream. + * + * Internally, it adds a special `pipe:` output argument to the FFmpeg command. + * + * Remember to specify the [`f` option](https://ffmpeg.org/ffmpeg.html#Main-options), + * which specifies the format of the output data. + * + * @param options FFmpeg options for this output. + * @returns A stream which will be read from the FFmpeg process' stdio. + * + * @example + * + * ```js + * import { createWriteStream } from "node:fs" + * import { Converter } from "ffmpeg-stream" + * + * const converter = new Converter() + * + * converter.createOutputStream({ f: "mp4" }) + * .pipe(createWriteStream("output.mp4")) + * + * await converter.run() + * ``` + */ + createOutputStream(options) { + const stream = new PassThrough(); + const fd = this.getUniqueFd(); + this.#pipes.push({ + type: "output", + options, + file: `pipe:${fd}`, + onSpawn: process => { + const stdio = process.stdio[fd]; + if (stdio == null) + throw Error(`output ${fd} is null`); + debugStream(stdio, `output ${fd}`); + stdio.pipe(stream); + }, + }); + return stream; + } + /** + * Defines an FFmpeg input stream from a temporary file. + * + * Creates a temporary file that you can write to and instructs FFmpeg to read from it. + * Note that the actual conversion process will not start until the file is fully written. + * + * Use this method if the format you want to read doesn't support non-seekable input. + * + * @param options FFmpeg options for this input. + * @returns A stream which will be written to the temporary file. + */ + createBufferedInputStream(options) { + const stream = new PassThrough(); + const file = getTmpPath("ffmpeg-"); + this.#pipes.push({ + type: "input", + options, + file, + onBegin: async () => { + await new Promise((resolve, reject) => { + const writer = createWriteStream(file); + stream.pipe(writer); + stream.on("end", () => { + dbg("input buffered stream end"); + resolve(); + }); + stream.on("error", err => { + dbg(`input buffered stream error: ${err.message}`); + reject(err); + }); + }); + }, + onFinish: async () => { + await unlink(file); + }, + }); + return stream; + } + /** + * Defines an FFmpeg output stream to a temporary file. + * + * Creates a temporary file that you can read from and instructs FFmpeg to write to it. + * Note that you will be able to read from the file only after the conversion process is finished. + * + * Use this method if the format you want to write doesn't support non-seekable output. + * + * @param options FFmpeg options for this output. + * @returns A stream which will be read from the temporary file. + */ + createBufferedOutputStream(options) { + const stream = new PassThrough(); + const file = getTmpPath("ffmpeg-"); + this.#pipes.push({ + type: "output", + options, + file, + onFinish: async () => { + await new Promise((resolve, reject) => { + const reader = createReadStream(file); + reader.pipe(stream); + reader.on("end", () => { + dbg("output buffered stream end"); + resolve(); + }); + reader.on("error", err => { + dbg(`output buffered stream error: ${err.message}`); + reject(err); + }); + }); + await unlink(file); + }, + }); + return stream; + } + /** + * Starts the conversion process. + * + * You can use {@link Converter.kill} to cancel the conversion. + * + * @returns A promise which resolves on normal exit or kill, but rejects on ffmpeg error. + */ + async run() { + const pipes = []; + try { + for (const pipe of this.#pipes) { + dbg(`prepare ${pipe.type}`); + await pipe.onBegin?.(); + pipes.push(pipe); + } + const args = this.getSpawnArgs(); + const stdio = this.getStdioArg(); + dbg(`spawn: ${this.#ffmpegPath} ${args.join(" ")}`); + dbg(`spawn stdio: ${stdio.join(" ")}`); + this.#process = spawn(this.#ffmpegPath, args, { stdio }); + const finished = this.handleProcess(); + for (const pipe of this.#pipes) { + pipe.onSpawn?.(this.#process); } - }, - ) - } - - this.process.on("error", err => { - dbg(`error: ${err.message}`) - reject(err) - }) - - this.process.on("exit", (code, signal) => { - dbg(`exit: code=${code ?? "unknown"} sig=${signal ?? "unknown"}`) - if (code == null) return resolve() - if (EXIT_CODES.includes(code)) return resolve() - const log = logLines.map(line => ` ${line}`).join("\n") - reject(Error(`Converting failed\n${log}`)) - }) - }) - } + if (this.#killed) { + this.#process.kill(); + } + await finished; + } + finally { + for (const pipe of pipes) { + await pipe.onFinish?.(); + } + } + } + /** + * Stops the conversion process. + */ + kill() { + this.#process?.kill(); + this.#killed = true; + } + getUniqueFd() { + return this.#fdCount++ + 3; + } + /** + * Returns stdio pipes which can be passed to {@link spawn}. + */ + getStdioArg() { + return ["ignore", "ignore", "pipe", ...Array(this.#fdCount).fill("pipe")]; + } + /** + * Returns arguments which can be passed to {@link spawn}. + */ + getSpawnArgs() { + const args = []; + for (const pipe of this.#pipes) { + if (pipe.type !== "input") + continue; + args.push(...stringifyArgs(pipe.options)); + args.push("-i", pipe.file); + } + for (const pipe of this.#pipes) { + if (pipe.type !== "output") + continue; + args.push(...stringifyArgs(pipe.options)); + args.push(pipe.file); + } + return args; + } + async handleProcess() { + await new Promise((resolve, reject) => { + let logSectionNum = 0; + const logLines = []; + assert(this.#process != null, "process should be initialized"); + if (this.#process.stderr != null) { + this.#process.stderr.setEncoding("utf8"); + this.#process.stderr.on("data", (data) => { + const lines = data.split(/\r\n|\r|\n/u); + for (const line of lines) { + // skip empty lines + if (/^\s*$/u.exec(line) != null) + continue; + // if not indented: increment section counter + if (/^\s/u.exec(line) == null) + logSectionNum++; + // only log sections following the first one + if (logSectionNum > 1) { + dbg(`log: ${line}`); + logLines.push(line); + } + } + }); + } + this.#process.on("error", err => { + dbg(`error: ${err.message}`); + reject(err); + }); + this.#process.on("exit", (code, signal) => { + dbg(`exit: code=${code ?? "unknown"} sig=${signal ?? "unknown"}`); + if (code == null) + return resolve(); + if (EXIT_CODES.includes(code)) + return resolve(); + const log = logLines.map(line => ` ${line}`).join("\n"); + reject(Error(`Converting failed\n${log}`)); + }); + }); + } } - /** * Stringifies FFmpeg options object into command line arguments array. - * - * @param {ConverterPipeOptions} options - * @returns {string[]} */ function stringifyArgs(options) { - /** @type {string[]} */ - const args = [] - - for (const [option, value] of Object.entries(options)) { - if (Array.isArray(value)) { - for (const element of value) { - if (element != null) { - args.push(`-${option}`) - args.push(String(element)) + const args = []; + for (const [option, value] of Object.entries(options)) { + if (Array.isArray(value)) { + for (const element of value) { + if (element != null) { + args.push(`-${option}`); + args.push(String(element)); + } + } + } + else if (value != null && value !== false) { + args.push(`-${option}`); + if (typeof value != "boolean") { + args.push(String(value)); + } } - } - } else if (value != null && value !== false) { - args.push(`-${option}`) - if (typeof value != "boolean") { - args.push(String(value)) - } } - } - - return args + return args; } - /** * Returns a random file path in the system's temporary directory. - * - * @param {string} [prefix] - * @param {string} [suffix] */ function getTmpPath(prefix = "", suffix = "") { - const dir = tmpdir() - const id = Math.random().toString(32).substring(2, 12) - return join(dir, `${prefix}${id}${suffix}`) + const dir = tmpdir(); + const id = Math.random().toString(32).substring(2, 12); + return join(dir, `${prefix}${id}${suffix}`); } - -/** - * @param {import("node:stream").Readable | import("node:stream").Writable} stream - * @param {string} name - */ function debugStream(stream, name) { - stream.on("error", err => { - dbg(`${name} error: ${err.message}`) - }) - stream.on( - "data", - /** @type {(data: Buffer | string) => void} */ data => { - dbg(`${name} data: ${data.length} bytes`) - }, - ) - stream.on("finish", () => { - dbg(`${name} finish`) - }) + stream.on("error", err => { + dbg(`${name} error: ${err.message}`); + }); + stream.on("data", (data) => { + dbg(`${name} data: ${data.length} bytes`); + }); + stream.on("finish", () => { + dbg(`${name} finish`); + }); } - -/** - * Options object for a single input or output of a {@link Converter}. - * - * These are the same options that you normally pass to the ffmpeg command in the terminal. - * Documentation for individual options can be found in the [ffmpeg docs](https://ffmpeg.org/ffmpeg.html#Main-options). - * - * To specify a boolean option, set it to `true`. - * To specify an option multiple times, use an array. - * Options with nullish or `false` values are ignored. - * - * @example - * - * ```js - * const options = { f: "image2", vcodec: "png" } - * ``` - * - * @typedef {Record | null | undefined>} ConverterPipeOptions - */ - -/** - * Data about a single input or output of a {@link Converter}. - * - * @ignore - * @internal - * @typedef {Object} ConverterPipe - * @property {"input" | "output"} type - * @property {ConverterPipeOptions} options - * @property {string} file - * @property {() => Promise} [onBegin] - * @property {(process: import("node:child_process").ChildProcess) => void} [onSpawn] - * @property {() => Promise} [onFinish] - */ diff --git a/lib/index.ts b/lib/index.ts new file mode 100644 index 0000000..beadb2a --- /dev/null +++ b/lib/index.ts @@ -0,0 +1,467 @@ +import debug from "debug" +import assert from "node:assert/strict" +import { spawn, type ChildProcess } from "node:child_process" +import { createReadStream, createWriteStream } from "node:fs" +import { unlink } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { PassThrough, type Readable, type Writable } from "node:stream" + +const dbg = debug("ffmpeg-stream") + +/** Exit codes which aren't considered errors */ +const EXIT_CODES = [0, 255] + +/** + * A class which wraps a FFmpeg process. + * + * @example + * + * ```js + * import { Converter } from "ffmpeg-stream" + * + * const converter = new Converter() + * + * converter.createInputFromFile("input.mp4") + * converter.createOutputToFile("output.webm") + * + * await converter.run() + * ``` + */ +export class Converter { + readonly #ffmpegPath: string + #fdCount = 0 + readonly #pipes: ConverterPipe[] = [] + #process?: ChildProcess + #killed = false + + /** + * Initializes the converter. + * + * Remember to call {@link Converter.run} to actually start the FFmpeg process. + * + * @param ffmpegPath Path to the FFmpeg executable. (default: `"ffmpeg"`) + */ + constructor(ffmpegPath = "ffmpeg") { + this.#ffmpegPath = ffmpegPath + } + + /** + * Defines an FFmpeg input file. + * + * This builds a command like the one you would normally use in the terminal. + * + * @param file Path to the input file. + * @param options FFmpeg options for this input. + * + * @example + * + * ```js + * import { Converter } from "ffmpeg-stream" + * + * const converter = new Converter() + * + * converter.createInputFromFile("input.mp4", { r: 30 }) + * // ffmpeg -r 30 -i input.mp4 ... + * + * await converter.run() + * ``` + */ + createInputFromFile(file: string, options: ConverterPipeOptions = {}) { + this.#pipes.push({ + type: "input", + options, + file, + }) + } + + /** + * Defines an FFmpeg output file. + * + * This builds a command like the one you would normally use in the terminal. + * + * @param file Path to the output file. + * @param options FFmpeg options for this output. + * + * @example + * + * ```js + * import { Converter } from "ffmpeg-stream" + * + * const converter = new Converter() + * + * converter.createOutputToFile("output.mp4", { vcodec: "libx264" }) + * // ffmpeg ... -vcodec libx264 output.mp4 + * + * await converter.run() + * ``` + */ + createOutputToFile(file: string, options: ConverterPipeOptions = {}) { + this.#pipes.push({ + type: "output", + options, + file, + }) + } + + /** + * Defines an FFmpeg input stream. + * + * Internally, it adds a special `pipe:` input argument to the FFmpeg command. + * + * Remember to specify the [`f` option](https://ffmpeg.org/ffmpeg.html#Main-options), + * which specifies the format of the input data. + * + * @param options FFmpeg options for this input. + * @returns A stream which will be written to the FFmpeg process' stdio. + * + * @example + * + * ```js + * import { createReadStream } from "node:fs" + * import { Converter } from "ffmpeg-stream" + * + * const converter = new Converter() + * + * createReadStream("input.mp4").pipe( + * converter.createInputStream({ f: "mp4" }), + * ) + * + * await converter.run() + * ``` + */ + createInputStream(options: ConverterPipeOptions): Writable { + const stream = new PassThrough() + const fd = this.getUniqueFd() + this.#pipes.push({ + type: "input", + options, + file: `pipe:${fd}`, + onSpawn: process => { + const stdio = process.stdio[fd] + if (stdio == null) throw Error(`input ${fd} is null`) + debugStream(stream, `input ${fd}`) + if (!("write" in stdio)) throw Error(`input ${fd} is not writable`) + stream.pipe(stdio) + }, + }) + + return stream + } + + /** + * Defines an FFmpeg output stream. + * + * Internally, it adds a special `pipe:` output argument to the FFmpeg command. + * + * Remember to specify the [`f` option](https://ffmpeg.org/ffmpeg.html#Main-options), + * which specifies the format of the output data. + * + * @param options FFmpeg options for this output. + * @returns A stream which will be read from the FFmpeg process' stdio. + * + * @example + * + * ```js + * import { createWriteStream } from "node:fs" + * import { Converter } from "ffmpeg-stream" + * + * const converter = new Converter() + * + * converter.createOutputStream({ f: "mp4" }) + * .pipe(createWriteStream("output.mp4")) + * + * await converter.run() + * ``` + */ + createOutputStream(options: ConverterPipeOptions): Readable { + const stream = new PassThrough() + const fd = this.getUniqueFd() + this.#pipes.push({ + type: "output", + options, + file: `pipe:${fd}`, + onSpawn: process => { + const stdio = process.stdio[fd] + if (stdio == null) throw Error(`output ${fd} is null`) + debugStream(stdio, `output ${fd}`) + stdio.pipe(stream) + }, + }) + return stream + } + + /** + * Defines an FFmpeg input stream from a temporary file. + * + * Creates a temporary file that you can write to and instructs FFmpeg to read from it. + * Note that the actual conversion process will not start until the file is fully written. + * + * Use this method if the format you want to read doesn't support non-seekable input. + * + * @param options FFmpeg options for this input. + * @returns A stream which will be written to the temporary file. + */ + createBufferedInputStream(options: ConverterPipeOptions): Writable { + const stream = new PassThrough() + const file = getTmpPath("ffmpeg-") + this.#pipes.push({ + type: "input", + options, + file, + onBegin: async () => { + await new Promise((resolve, reject) => { + const writer = createWriteStream(file) + stream.pipe(writer) + stream.on("end", () => { + dbg("input buffered stream end") + resolve() + }) + stream.on("error", err => { + dbg(`input buffered stream error: ${err.message}`) + reject(err) + }) + }) + }, + onFinish: async () => { + await unlink(file) + }, + }) + return stream + } + + /** + * Defines an FFmpeg output stream to a temporary file. + * + * Creates a temporary file that you can read from and instructs FFmpeg to write to it. + * Note that you will be able to read from the file only after the conversion process is finished. + * + * Use this method if the format you want to write doesn't support non-seekable output. + * + * @param options FFmpeg options for this output. + * @returns A stream which will be read from the temporary file. + */ + createBufferedOutputStream(options: ConverterPipeOptions): Readable { + const stream = new PassThrough() + const file = getTmpPath("ffmpeg-") + this.#pipes.push({ + type: "output", + options, + file, + onFinish: async () => { + await new Promise((resolve, reject) => { + const reader = createReadStream(file) + reader.pipe(stream) + reader.on("end", () => { + dbg("output buffered stream end") + resolve() + }) + reader.on("error", err => { + dbg(`output buffered stream error: ${err.message}`) + reject(err) + }) + }) + await unlink(file) + }, + }) + return stream + } + + /** + * Starts the conversion process. + * + * You can use {@link Converter.kill} to cancel the conversion. + * + * @returns A promise which resolves on normal exit or kill, but rejects on ffmpeg error. + */ + async run() { + const pipes: ConverterPipe[] = [] + try { + for (const pipe of this.#pipes) { + dbg(`prepare ${pipe.type}`) + await pipe.onBegin?.() + pipes.push(pipe) + } + + const args = this.getSpawnArgs() + const stdio = this.getStdioArg() + dbg(`spawn: ${this.#ffmpegPath} ${args.join(" ")}`) + dbg(`spawn stdio: ${stdio.join(" ")}`) + this.#process = spawn(this.#ffmpegPath, args, { stdio }) + const finished = this.handleProcess() + + for (const pipe of this.#pipes) { + pipe.onSpawn?.(this.#process) + } + + if (this.#killed) { + this.#process.kill() + } + + await finished + } finally { + for (const pipe of pipes) { + await pipe.onFinish?.() + } + } + } + + /** + * Stops the conversion process. + */ + kill() { + this.#process?.kill() + this.#killed = true + } + + private getUniqueFd() { + return this.#fdCount++ + 3 + } + + /** + * Returns stdio pipes which can be passed to {@link spawn}. + */ + private getStdioArg(): Array<"ignore" | "pipe"> { + return ["ignore", "ignore", "pipe", ...Array<"pipe">(this.#fdCount).fill("pipe")] + } + + /** + * Returns arguments which can be passed to {@link spawn}. + */ + private getSpawnArgs(): string[] { + const args: string[] = [] + + for (const pipe of this.#pipes) { + if (pipe.type !== "input") continue + args.push(...stringifyArgs(pipe.options)) + args.push("-i", pipe.file) + } + for (const pipe of this.#pipes) { + if (pipe.type !== "output") continue + args.push(...stringifyArgs(pipe.options)) + args.push(pipe.file) + } + + return args + } + + private async handleProcess() { + await new Promise((resolve, reject) => { + let logSectionNum = 0 + const logLines: string[] = [] + + assert(this.#process != null, "process should be initialized") + + if (this.#process.stderr != null) { + this.#process.stderr.setEncoding("utf8") + + this.#process.stderr.on("data", (data: string) => { + const lines = data.split(/\r\n|\r|\n/u) + for (const line of lines) { + // skip empty lines + if (/^\s*$/u.exec(line) != null) continue + // if not indented: increment section counter + if (/^\s/u.exec(line) == null) logSectionNum++ + // only log sections following the first one + if (logSectionNum > 1) { + dbg(`log: ${line}`) + logLines.push(line) + } + } + }) + } + + this.#process.on("error", err => { + dbg(`error: ${err.message}`) + reject(err) + }) + + this.#process.on("exit", (code, signal) => { + dbg(`exit: code=${code ?? "unknown"} sig=${signal ?? "unknown"}`) + if (code == null) return resolve() + if (EXIT_CODES.includes(code)) return resolve() + const log = logLines.map(line => ` ${line}`).join("\n") + reject(Error(`Converting failed\n${log}`)) + }) + }) + } +} + +/** + * Stringifies FFmpeg options object into command line arguments array. + */ +function stringifyArgs(options: ConverterPipeOptions): string[] { + const args: string[] = [] + + for (const [option, value] of Object.entries(options)) { + if (Array.isArray(value)) { + for (const element of value) { + if (element != null) { + args.push(`-${option}`) + args.push(String(element)) + } + } + } else if (value != null && value !== false) { + args.push(`-${option}`) + if (typeof value != "boolean") { + args.push(String(value)) + } + } + } + + return args +} + +/** + * Returns a random file path in the system's temporary directory. + */ +function getTmpPath(prefix = "", suffix = "") { + const dir = tmpdir() + const id = Math.random().toString(32).substring(2, 12) + return join(dir, `${prefix}${id}${suffix}`) +} + +function debugStream(stream: Readable | Writable, name: string) { + stream.on("error", err => { + dbg(`${name} error: ${err.message}`) + }) + stream.on("data", (data: Buffer | string) => { + dbg(`${name} data: ${data.length} bytes`) + }) + stream.on("finish", () => { + dbg(`${name} finish`) + }) +} + +/** + * Options object for a single input or output of a {@link Converter}. + * + * These are the same options that you normally pass to the ffmpeg command in the terminal. + * Documentation for individual options can be found in the [ffmpeg docs](https://ffmpeg.org/ffmpeg.html#Main-options). + * + * To specify a boolean option, set it to `true`. + * To specify an option multiple times, use an array. + * Options with nullish or `false` values are ignored. + * + * @example + * + * ```js + * const options = { f: "image2", vcodec: "png" } + * ``` + */ +export type ConverterPipeOptions = Record< + string, + string | number | boolean | Array | null | undefined +> + +/** + * Data about a single input or output of a {@link Converter}. + */ +interface ConverterPipe { + type: "input" | "output" + options: ConverterPipeOptions + file: string + onBegin?: () => Promise + onSpawn?: (process: ChildProcess) => void + onFinish?: () => Promise +} diff --git a/tests/index.test.js b/tests/index.test.ts similarity index 98% rename from tests/index.test.js rename to tests/index.test.ts index 72f3213..1e990d7 100644 --- a/tests/index.test.js +++ b/tests/index.test.ts @@ -3,7 +3,7 @@ import assert from "node:assert/strict" import { createReadStream, createWriteStream } from "node:fs" import { mkdir, rm } from "node:fs/promises" import { afterEach, beforeEach, test } from "node:test" -import { Converter } from "../lib/index.js" +import { Converter } from "../lib/index.ts" const mediaDir = `${import.meta.dirname}/media` diff --git a/tsconfig.build.json b/tsconfig.build.json index 24a1d22..9c3f982 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -2,8 +2,7 @@ "extends": "./tsconfig.json", "compilerOptions": { "declaration": true, - "emitDeclarationOnly": true, "noEmit": false }, - "include": ["lib/**/*.js"] + "include": ["lib"] } diff --git a/tsconfig.json b/tsconfig.json index 3aa57d3..0fa053d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,13 +1,14 @@ { "compilerOptions": { - "allowJs": true, - "checkJs": true, + "erasableSyntaxOnly": true, "lib": ["ESNext", "DOM"], "module": "nodenext", "moduleResolution": "nodenext", "noEmit": true, + "rewriteRelativeImportExtensions": true, "strict": true, "target": "esnext", - "types": ["@types/node"] + "types": ["node"], + "verbatimModuleSyntax": true } } From 8390d693a5b1aaecc653ed1f67c5f8684d0fe96f Mon Sep 17 00:00:00 2001 From: Nikita Date: Sun, 2 Aug 2026 18:00:03 +0200 Subject: [PATCH 2/2] delete js --- lib/index.js | 411 --------------------------------------------------- 1 file changed, 411 deletions(-) delete mode 100644 lib/index.js diff --git a/lib/index.js b/lib/index.js deleted file mode 100644 index 8acf869..0000000 --- a/lib/index.js +++ /dev/null @@ -1,411 +0,0 @@ -import debug from "debug"; -import assert from "node:assert"; -import { spawn } from "node:child_process"; -import { createReadStream, createWriteStream } from "node:fs"; -import { unlink } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { PassThrough } from "node:stream"; -const dbg = debug("ffmpeg-stream"); -const EXIT_CODES = [0, 255]; -/** - * A class which wraps a FFmpeg process. - * - * @example - * - * ```js - * import { Converter } from "ffmpeg-stream" - * - * const converter = new Converter() - * - * converter.createInputFromFile("input.mp4") - * converter.createOutputToFile("output.webm") - * - * await converter.run() - * ``` - */ -export class Converter { - #ffmpegPath; - #fdCount = 0; - #pipes = []; - #process; - #killed = false; - /** - * Initializes the converter. - * - * Remember to call {@link Converter.run} to actually start the FFmpeg process. - * - * @param ffmpegPath Path to the FFmpeg executable. (default: `"ffmpeg"`) - */ - constructor(ffmpegPath = "ffmpeg") { - this.#ffmpegPath = ffmpegPath; - } - /** - * Defines an FFmpeg input file. - * - * This builds a command like the one you would normally use in the terminal. - * - * @param file Path to the input file. - * @param options FFmpeg options for this input. - * - * @example - * - * ```js - * import { Converter } from "ffmpeg-stream" - * - * const converter = new Converter() - * - * converter.createInputFromFile("input.mp4", { r: 30 }) - * // ffmpeg -r 30 -i input.mp4 ... - * - * await converter.run() - * ``` - */ - createInputFromFile(file, options = {}) { - this.#pipes.push({ - type: "input", - options, - file, - }); - } - /** - * Defines an FFmpeg output file. - * - * This builds a command like the one you would normally use in the terminal. - * - * @param file Path to the output file. - * @param options FFmpeg options for this output. - * - * @example - * - * ```js - * import { Converter } from "ffmpeg-stream" - * - * const converter = new Converter() - * - * converter.createOutputToFile("output.mp4", { vcodec: "libx264" }) - * // ffmpeg ... -vcodec libx264 output.mp4 - * - * await converter.run() - * ``` - */ - createOutputToFile(file, options = {}) { - this.#pipes.push({ - type: "output", - options, - file, - }); - } - /** - * Defines an FFmpeg input stream. - * - * Internally, it adds a special `pipe:` input argument to the FFmpeg command. - * - * Remember to specify the [`f` option](https://ffmpeg.org/ffmpeg.html#Main-options), - * which specifies the format of the input data. - * - * @param options FFmpeg options for this input. - * @returns A stream which will be written to the FFmpeg process' stdio. - * - * @example - * - * ```js - * import { createReadStream } from "node:fs" - * import { Converter } from "ffmpeg-stream" - * - * const converter = new Converter() - * - * createReadStream("input.mp4").pipe( - * converter.createInputStream({ f: "mp4" }), - * ) - * - * await converter.run() - * ``` - */ - createInputStream(options) { - const stream = new PassThrough(); - const fd = this.getUniqueFd(); - this.#pipes.push({ - type: "input", - options, - file: `pipe:${fd}`, - onSpawn: process => { - const stdio = process.stdio[fd]; - if (stdio == null) - throw Error(`input ${fd} is null`); - debugStream(stream, `input ${fd}`); - if (!("write" in stdio)) - throw Error(`input ${fd} is not writable`); - stream.pipe(stdio); - }, - }); - return stream; - } - /** - * Defines an FFmpeg output stream. - * - * Internally, it adds a special `pipe:` output argument to the FFmpeg command. - * - * Remember to specify the [`f` option](https://ffmpeg.org/ffmpeg.html#Main-options), - * which specifies the format of the output data. - * - * @param options FFmpeg options for this output. - * @returns A stream which will be read from the FFmpeg process' stdio. - * - * @example - * - * ```js - * import { createWriteStream } from "node:fs" - * import { Converter } from "ffmpeg-stream" - * - * const converter = new Converter() - * - * converter.createOutputStream({ f: "mp4" }) - * .pipe(createWriteStream("output.mp4")) - * - * await converter.run() - * ``` - */ - createOutputStream(options) { - const stream = new PassThrough(); - const fd = this.getUniqueFd(); - this.#pipes.push({ - type: "output", - options, - file: `pipe:${fd}`, - onSpawn: process => { - const stdio = process.stdio[fd]; - if (stdio == null) - throw Error(`output ${fd} is null`); - debugStream(stdio, `output ${fd}`); - stdio.pipe(stream); - }, - }); - return stream; - } - /** - * Defines an FFmpeg input stream from a temporary file. - * - * Creates a temporary file that you can write to and instructs FFmpeg to read from it. - * Note that the actual conversion process will not start until the file is fully written. - * - * Use this method if the format you want to read doesn't support non-seekable input. - * - * @param options FFmpeg options for this input. - * @returns A stream which will be written to the temporary file. - */ - createBufferedInputStream(options) { - const stream = new PassThrough(); - const file = getTmpPath("ffmpeg-"); - this.#pipes.push({ - type: "input", - options, - file, - onBegin: async () => { - await new Promise((resolve, reject) => { - const writer = createWriteStream(file); - stream.pipe(writer); - stream.on("end", () => { - dbg("input buffered stream end"); - resolve(); - }); - stream.on("error", err => { - dbg(`input buffered stream error: ${err.message}`); - reject(err); - }); - }); - }, - onFinish: async () => { - await unlink(file); - }, - }); - return stream; - } - /** - * Defines an FFmpeg output stream to a temporary file. - * - * Creates a temporary file that you can read from and instructs FFmpeg to write to it. - * Note that you will be able to read from the file only after the conversion process is finished. - * - * Use this method if the format you want to write doesn't support non-seekable output. - * - * @param options FFmpeg options for this output. - * @returns A stream which will be read from the temporary file. - */ - createBufferedOutputStream(options) { - const stream = new PassThrough(); - const file = getTmpPath("ffmpeg-"); - this.#pipes.push({ - type: "output", - options, - file, - onFinish: async () => { - await new Promise((resolve, reject) => { - const reader = createReadStream(file); - reader.pipe(stream); - reader.on("end", () => { - dbg("output buffered stream end"); - resolve(); - }); - reader.on("error", err => { - dbg(`output buffered stream error: ${err.message}`); - reject(err); - }); - }); - await unlink(file); - }, - }); - return stream; - } - /** - * Starts the conversion process. - * - * You can use {@link Converter.kill} to cancel the conversion. - * - * @returns A promise which resolves on normal exit or kill, but rejects on ffmpeg error. - */ - async run() { - const pipes = []; - try { - for (const pipe of this.#pipes) { - dbg(`prepare ${pipe.type}`); - await pipe.onBegin?.(); - pipes.push(pipe); - } - const args = this.getSpawnArgs(); - const stdio = this.getStdioArg(); - dbg(`spawn: ${this.#ffmpegPath} ${args.join(" ")}`); - dbg(`spawn stdio: ${stdio.join(" ")}`); - this.#process = spawn(this.#ffmpegPath, args, { stdio }); - const finished = this.handleProcess(); - for (const pipe of this.#pipes) { - pipe.onSpawn?.(this.#process); - } - if (this.#killed) { - this.#process.kill(); - } - await finished; - } - finally { - for (const pipe of pipes) { - await pipe.onFinish?.(); - } - } - } - /** - * Stops the conversion process. - */ - kill() { - this.#process?.kill(); - this.#killed = true; - } - getUniqueFd() { - return this.#fdCount++ + 3; - } - /** - * Returns stdio pipes which can be passed to {@link spawn}. - */ - getStdioArg() { - return ["ignore", "ignore", "pipe", ...Array(this.#fdCount).fill("pipe")]; - } - /** - * Returns arguments which can be passed to {@link spawn}. - */ - getSpawnArgs() { - const args = []; - for (const pipe of this.#pipes) { - if (pipe.type !== "input") - continue; - args.push(...stringifyArgs(pipe.options)); - args.push("-i", pipe.file); - } - for (const pipe of this.#pipes) { - if (pipe.type !== "output") - continue; - args.push(...stringifyArgs(pipe.options)); - args.push(pipe.file); - } - return args; - } - async handleProcess() { - await new Promise((resolve, reject) => { - let logSectionNum = 0; - const logLines = []; - assert(this.#process != null, "process should be initialized"); - if (this.#process.stderr != null) { - this.#process.stderr.setEncoding("utf8"); - this.#process.stderr.on("data", (data) => { - const lines = data.split(/\r\n|\r|\n/u); - for (const line of lines) { - // skip empty lines - if (/^\s*$/u.exec(line) != null) - continue; - // if not indented: increment section counter - if (/^\s/u.exec(line) == null) - logSectionNum++; - // only log sections following the first one - if (logSectionNum > 1) { - dbg(`log: ${line}`); - logLines.push(line); - } - } - }); - } - this.#process.on("error", err => { - dbg(`error: ${err.message}`); - reject(err); - }); - this.#process.on("exit", (code, signal) => { - dbg(`exit: code=${code ?? "unknown"} sig=${signal ?? "unknown"}`); - if (code == null) - return resolve(); - if (EXIT_CODES.includes(code)) - return resolve(); - const log = logLines.map(line => ` ${line}`).join("\n"); - reject(Error(`Converting failed\n${log}`)); - }); - }); - } -} -/** - * Stringifies FFmpeg options object into command line arguments array. - */ -function stringifyArgs(options) { - const args = []; - for (const [option, value] of Object.entries(options)) { - if (Array.isArray(value)) { - for (const element of value) { - if (element != null) { - args.push(`-${option}`); - args.push(String(element)); - } - } - } - else if (value != null && value !== false) { - args.push(`-${option}`); - if (typeof value != "boolean") { - args.push(String(value)); - } - } - } - return args; -} -/** - * Returns a random file path in the system's temporary directory. - */ -function getTmpPath(prefix = "", suffix = "") { - const dir = tmpdir(); - const id = Math.random().toString(32).substring(2, 12); - return join(dir, `${prefix}${id}${suffix}`); -} -function debugStream(stream, name) { - stream.on("error", err => { - dbg(`${name} error: ${err.message}`); - }); - stream.on("data", (data) => { - dbg(`${name} data: ${data.length} bytes`); - }); - stream.on("finish", () => { - dbg(`${name} finish`); - }); -}