From eda0ae6b1d88664392a85e1446511f9528c014e3 Mon Sep 17 00:00:00 2001 From: Joao Carreira Date: Mon, 28 Sep 2026 10:48:47 +0100 Subject: [PATCH] feat: compression 17 --- src/helpers/ffmpeg-log.server.ts | 129 ++++++++++++++++++ src/pages/api/assets/compression/transcode.ts | 4 +- 2 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 src/helpers/ffmpeg-log.server.ts diff --git a/src/helpers/ffmpeg-log.server.ts b/src/helpers/ffmpeg-log.server.ts new file mode 100644 index 0000000..599b09a --- /dev/null +++ b/src/helpers/ffmpeg-log.server.ts @@ -0,0 +1,129 @@ +import { createWriteStream } from "node:fs"; +import { spawn } from "node:child_process"; +import path from "node:path"; + +const STDERR_TAIL_BYTES = 64 * 1024; + +const tokenizeCommand = (input: string): string[] => { + const args: string[] = []; + let current = ""; + let quote: "'" | '"' | null = null; + let escaping = false; + let tokenStarted = false; + + for (const char of input) { + if (escaping) { + current += char; + escaping = false; + tokenStarted = true; + continue; + } + + if (char === "\\" && quote !== "'") { + escaping = true; + tokenStarted = true; + continue; + } + + if (quote) { + if (char === quote) { + quote = null; + } else { + current += char; + } + tokenStarted = true; + continue; + } + + if (char === "'" || char === '"') { + quote = char; + tokenStarted = true; + continue; + } + + if (/\s/.test(char)) { + if (tokenStarted) { + args.push(current); + current = ""; + tokenStarted = false; + } + continue; + } + + current += char; + tokenStarted = true; + } + + if (escaping) throw new Error("FFmpeg command ends with an incomplete escape sequence"); + if (quote) throw new Error("FFmpeg command contains an unterminated quote"); + if (tokenStarted) args.push(current); + + return args; +}; + +const getLogPath = (outputPath: string) => { + const parsed = path.parse(outputPath); + return path.join(parsed.dir, `${parsed.name}.log`); +}; + +const formatArgument = (value: string) => + /\s/.test(value) ? JSON.stringify(value) : value; + +export const runLoggedFfmpegCommand = async ( + command: string, + inputPath: string, + outputPath: string +) => { + const tokens = tokenizeCommand(command); + if (tokens.length === 0) throw new Error("FFmpeg command is empty"); + + const resolved = tokens.map((token) => token + .split("{input}").join(inputPath) + .split("{output}").join(outputPath)); + + // Support both "ffmpeg -i ..." and argument-only settings such as "-i ...". + const commandIncludesExecutable = !resolved[0]?.startsWith("-"); + const executable = commandIncludesExecutable ? resolved[0] : "ffmpeg"; + const args = commandIncludesExecutable ? resolved.slice(1) : resolved; + const logPath = getLogPath(outputPath); + const log = createWriteStream(logPath, { flags: "w" }); + + log.write(`Command: ${[executable, ...args].map(formatArgument).join(" ")}\n\n`); + + await new Promise((resolve, reject) => { + const child = spawn(executable, args, { shell: false }); + let stderrTail = ""; + let settled = false; + + child.stdout.on("data", (chunk: Buffer) => { + log.write(chunk); + }); + + child.stderr.on("data", (chunk: Buffer) => { + log.write(chunk); + stderrTail = (stderrTail + chunk.toString()).slice(-STDERR_TAIL_BYTES); + }); + + const finish = (error?: Error) => { + if (settled) return; + settled = true; + log.end(() => error ? reject(error) : resolve()); + }; + + child.on("error", (error) => { + log.write(`\n\nFailed to start FFmpeg: ${error.message}\n`); + finish(error); + }); + + child.on("close", (code, signal) => { + if (settled) return; + + log.write( + `\n\nFFmpeg exited with code ${code ?? "unknown"}${signal ? ` (${signal})` : ""}\n` + ); + + if (code === 0) { + finish(); + return; + } + diff --git a/src/pages/api/assets/compression/transcode.ts b/src/pages/api/assets/compression/transcode.ts index 4c523c7..6ed8cab 100644 --- a/src/pages/api/assets/compression/transcode.ts +++ b/src/pages/api/assets/compression/transcode.ts @@ -45,7 +45,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const originalStat = await stat(inputPath); const command = await getFfmpegCommand(currentUser.id); - await runFfmpegCommand(command, inputPath, outputPath); + const runLoggedFfmpegCommand: typeof runFfmpegCommand = + (await import("@/helpers/ffmpeg-log.server")).runLoggedFfmpegCommand; + await runLoggedFfmpegCommand(command, inputPath, outputPath); const outputStat = await stat(outputPath); const outputFileName = outputPath.split("/").pop() || `${asset.id}-compressed.mp4`;