|
@@ -1,33 +1,32 @@
|
|
import pathToFfmpeg from "ffmpeg-static";
|
|
import pathToFfmpeg from "ffmpeg-static";
|
|
-import { existsSync } from "node:fs";
|
|
|
|
import fs from "node:fs/promises";
|
|
import fs from "node:fs/promises";
|
|
-import { ElectronFile } from "../../types/ipc";
|
|
|
|
import log from "../log";
|
|
import log from "../log";
|
|
-import { writeStream } from "../stream";
|
|
|
|
-import { generateTempFilePath, getTempDirPath } from "../temp";
|
|
|
|
-import { execAsync } from "../util";
|
|
|
|
|
|
+import { withTimeout } from "../utils";
|
|
|
|
+import { execAsync } from "../utils-electron";
|
|
|
|
+import { deleteTempFile, makeTempFilePath } from "../utils-temp";
|
|
|
|
|
|
-const INPUT_PATH_PLACEHOLDER = "INPUT";
|
|
|
|
-const FFMPEG_PLACEHOLDER = "FFMPEG";
|
|
|
|
-const OUTPUT_PATH_PLACEHOLDER = "OUTPUT";
|
|
|
|
|
|
+/* Duplicated in the web app's code (used by the WASM FFmpeg implementation). */
|
|
|
|
+const ffmpegPathPlaceholder = "FFMPEG";
|
|
|
|
+const inputPathPlaceholder = "INPUT";
|
|
|
|
+const outputPathPlaceholder = "OUTPUT";
|
|
|
|
|
|
/**
|
|
/**
|
|
- * Run a ffmpeg command
|
|
|
|
|
|
+ * Run a FFmpeg command
|
|
*
|
|
*
|
|
- * [Note: FFMPEG in Electron]
|
|
|
|
|
|
+ * [Note: FFmpeg in Electron]
|
|
*
|
|
*
|
|
- * There is a wasm build of FFMPEG, but that is currently 10-20 times slower
|
|
|
|
|
|
+ * There is a wasm build of FFmpeg, but that is currently 10-20 times slower
|
|
* that the native build. That is slow enough to be unusable for our purposes.
|
|
* that the native build. That is slow enough to be unusable for our purposes.
|
|
* https://ffmpegwasm.netlify.app/docs/performance
|
|
* https://ffmpegwasm.netlify.app/docs/performance
|
|
*
|
|
*
|
|
- * So the alternative is to bundle a ffmpeg binary with our app. e.g.
|
|
|
|
|
|
+ * So the alternative is to bundle a FFmpeg executable binary with our app. e.g.
|
|
*
|
|
*
|
|
* yarn add fluent-ffmpeg ffmpeg-static ffprobe-static
|
|
* yarn add fluent-ffmpeg ffmpeg-static ffprobe-static
|
|
*
|
|
*
|
|
* (we only use ffmpeg-static, the rest are mentioned for completeness' sake).
|
|
* (we only use ffmpeg-static, the rest are mentioned for completeness' sake).
|
|
*
|
|
*
|
|
- * Interestingly, Electron already bundles an ffmpeg library (it comes from the
|
|
|
|
- * ffmpeg fork maintained by Chromium).
|
|
|
|
|
|
+ * Interestingly, Electron already bundles an binary FFmpeg library (it comes
|
|
|
|
+ * from the ffmpeg fork maintained by Chromium).
|
|
* https://chromium.googlesource.com/chromium/third_party/ffmpeg
|
|
* https://chromium.googlesource.com/chromium/third_party/ffmpeg
|
|
* https://stackoverflow.com/questions/53963672/what-version-of-ffmpeg-is-bundled-inside-electron
|
|
* https://stackoverflow.com/questions/53963672/what-version-of-ffmpeg-is-bundled-inside-electron
|
|
*
|
|
*
|
|
@@ -36,84 +35,74 @@ const OUTPUT_PATH_PLACEHOLDER = "OUTPUT";
|
|
* $ file ente.app/Contents/Frameworks/Electron\ Framework.framework/Versions/Current/Libraries/libffmpeg.dylib
|
|
* $ file ente.app/Contents/Frameworks/Electron\ Framework.framework/Versions/Current/Libraries/libffmpeg.dylib
|
|
* .../libffmpeg.dylib: Mach-O 64-bit dynamically linked shared library arm64
|
|
* .../libffmpeg.dylib: Mach-O 64-bit dynamically linked shared library arm64
|
|
*
|
|
*
|
|
- * I'm not sure if our code is supposed to be able to use it, and how.
|
|
|
|
|
|
+ * But I'm not sure if our code is supposed to be able to use it, and how.
|
|
*/
|
|
*/
|
|
-export async function runFFmpegCmd(
|
|
|
|
- cmd: string[],
|
|
|
|
- inputFile: File | ElectronFile,
|
|
|
|
- outputFileName: string,
|
|
|
|
- dontTimeout?: boolean,
|
|
|
|
-) {
|
|
|
|
- let inputFilePath = null;
|
|
|
|
- let createdTempInputFile = null;
|
|
|
|
|
|
+export const ffmpegExec = async (
|
|
|
|
+ command: string[],
|
|
|
|
+ dataOrPath: Uint8Array | string,
|
|
|
|
+ outputFileExtension: string,
|
|
|
|
+ timeoutMS: number,
|
|
|
|
+): Promise<Uint8Array> => {
|
|
|
|
+ // TODO (MR): This currently copies files for both input and output. This
|
|
|
|
+ // needs to be tested extremely large video files when invoked downstream of
|
|
|
|
+ // `convertToMP4` in the web code.
|
|
|
|
+
|
|
|
|
+ let inputFilePath: string;
|
|
|
|
+ let isInputFileTemporary: boolean;
|
|
|
|
+ if (dataOrPath instanceof Uint8Array) {
|
|
|
|
+ inputFilePath = await makeTempFilePath();
|
|
|
|
+ isInputFileTemporary = true;
|
|
|
|
+ } else {
|
|
|
|
+ inputFilePath = dataOrPath;
|
|
|
|
+ isInputFileTemporary = false;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ const outputFilePath = await makeTempFilePath(outputFileExtension);
|
|
try {
|
|
try {
|
|
- if (!existsSync(inputFile.path)) {
|
|
|
|
- const tempFilePath = await generateTempFilePath(inputFile.name);
|
|
|
|
- await writeStream(tempFilePath, await inputFile.stream());
|
|
|
|
- inputFilePath = tempFilePath;
|
|
|
|
- createdTempInputFile = true;
|
|
|
|
- } else {
|
|
|
|
- inputFilePath = inputFile.path;
|
|
|
|
- }
|
|
|
|
- const outputFileData = await runFFmpegCmd_(
|
|
|
|
- cmd,
|
|
|
|
|
|
+ if (dataOrPath instanceof Uint8Array)
|
|
|
|
+ await fs.writeFile(inputFilePath, dataOrPath);
|
|
|
|
+
|
|
|
|
+ const cmd = substitutePlaceholders(
|
|
|
|
+ command,
|
|
inputFilePath,
|
|
inputFilePath,
|
|
- outputFileName,
|
|
|
|
- dontTimeout,
|
|
|
|
|
|
+ outputFilePath,
|
|
);
|
|
);
|
|
- return new File([outputFileData], outputFileName);
|
|
|
|
|
|
+
|
|
|
|
+ if (timeoutMS) await withTimeout(execAsync(cmd), 30 * 1000);
|
|
|
|
+ else await execAsync(cmd);
|
|
|
|
+
|
|
|
|
+ return fs.readFile(outputFilePath);
|
|
} finally {
|
|
} finally {
|
|
- if (createdTempInputFile) {
|
|
|
|
- await deleteTempFile(inputFilePath);
|
|
|
|
|
|
+ try {
|
|
|
|
+ if (isInputFileTemporary) await deleteTempFile(inputFilePath);
|
|
|
|
+ await deleteTempFile(outputFilePath);
|
|
|
|
+ } catch (e) {
|
|
|
|
+ log.error("Could not clean up temp files", e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
-}
|
|
|
|
|
|
+};
|
|
|
|
|
|
-export async function runFFmpegCmd_(
|
|
|
|
- cmd: string[],
|
|
|
|
|
|
+const substitutePlaceholders = (
|
|
|
|
+ command: string[],
|
|
inputFilePath: string,
|
|
inputFilePath: string,
|
|
- outputFileName: string,
|
|
|
|
- dontTimeout = false,
|
|
|
|
-) {
|
|
|
|
- let tempOutputFilePath: string;
|
|
|
|
- try {
|
|
|
|
- tempOutputFilePath = await generateTempFilePath(outputFileName);
|
|
|
|
-
|
|
|
|
- cmd = cmd.map((cmdPart) => {
|
|
|
|
- if (cmdPart === FFMPEG_PLACEHOLDER) {
|
|
|
|
- return ffmpegBinaryPath();
|
|
|
|
- } else if (cmdPart === INPUT_PATH_PLACEHOLDER) {
|
|
|
|
- return inputFilePath;
|
|
|
|
- } else if (cmdPart === OUTPUT_PATH_PLACEHOLDER) {
|
|
|
|
- return tempOutputFilePath;
|
|
|
|
- } else {
|
|
|
|
- return cmdPart;
|
|
|
|
- }
|
|
|
|
- });
|
|
|
|
-
|
|
|
|
- if (dontTimeout) {
|
|
|
|
- await execAsync(cmd);
|
|
|
|
|
|
+ outputFilePath: string,
|
|
|
|
+) =>
|
|
|
|
+ command.map((segment) => {
|
|
|
|
+ if (segment == ffmpegPathPlaceholder) {
|
|
|
|
+ return ffmpegBinaryPath();
|
|
|
|
+ } else if (segment == inputPathPlaceholder) {
|
|
|
|
+ return inputFilePath;
|
|
|
|
+ } else if (segment == outputPathPlaceholder) {
|
|
|
|
+ return outputFilePath;
|
|
} else {
|
|
} else {
|
|
- await promiseWithTimeout(execAsync(cmd), 30 * 1000);
|
|
|
|
|
|
+ return segment;
|
|
}
|
|
}
|
|
-
|
|
|
|
- if (!existsSync(tempOutputFilePath)) {
|
|
|
|
- throw new Error("ffmpeg output file not found");
|
|
|
|
- }
|
|
|
|
- const outputFile = await fs.readFile(tempOutputFilePath);
|
|
|
|
- return new Uint8Array(outputFile);
|
|
|
|
- } catch (e) {
|
|
|
|
- log.error("FFMPEG command failed", e);
|
|
|
|
- throw e;
|
|
|
|
- } finally {
|
|
|
|
- await deleteTempFile(tempOutputFilePath);
|
|
|
|
- }
|
|
|
|
-}
|
|
|
|
|
|
+ });
|
|
|
|
|
|
/**
|
|
/**
|
|
* Return the path to the `ffmpeg` binary.
|
|
* Return the path to the `ffmpeg` binary.
|
|
*
|
|
*
|
|
- * At runtime, the ffmpeg binary is present in a path like (macOS example):
|
|
|
|
|
|
+ * At runtime, the FFmpeg binary is present in a path like (macOS example):
|
|
* `ente.app/Contents/Resources/app.asar.unpacked/node_modules/ffmpeg-static/ffmpeg`
|
|
* `ente.app/Contents/Resources/app.asar.unpacked/node_modules/ffmpeg-static/ffmpeg`
|
|
*/
|
|
*/
|
|
const ffmpegBinaryPath = () => {
|
|
const ffmpegBinaryPath = () => {
|
|
@@ -122,40 +111,3 @@ const ffmpegBinaryPath = () => {
|
|
// https://github.com/eugeneware/ffmpeg-static/issues/16
|
|
// https://github.com/eugeneware/ffmpeg-static/issues/16
|
|
return pathToFfmpeg.replace("app.asar", "app.asar.unpacked");
|
|
return pathToFfmpeg.replace("app.asar", "app.asar.unpacked");
|
|
};
|
|
};
|
|
-
|
|
|
|
-export async function writeTempFile(fileStream: Uint8Array, fileName: string) {
|
|
|
|
- const tempFilePath = await generateTempFilePath(fileName);
|
|
|
|
- await fs.writeFile(tempFilePath, fileStream);
|
|
|
|
- return tempFilePath;
|
|
|
|
-}
|
|
|
|
-
|
|
|
|
-export async function deleteTempFile(tempFilePath: string) {
|
|
|
|
- const tempDirPath = await getTempDirPath();
|
|
|
|
- if (!tempFilePath.startsWith(tempDirPath))
|
|
|
|
- log.error("Attempting to delete a non-temp file ${tempFilePath}");
|
|
|
|
- await fs.rm(tempFilePath, { force: true });
|
|
|
|
-}
|
|
|
|
-
|
|
|
|
-const promiseWithTimeout = async <T>(
|
|
|
|
- request: Promise<T>,
|
|
|
|
- timeout: number,
|
|
|
|
-): Promise<T> => {
|
|
|
|
- const timeoutRef: {
|
|
|
|
- current: NodeJS.Timeout;
|
|
|
|
- } = { current: null };
|
|
|
|
- const rejectOnTimeout = new Promise<null>((_, reject) => {
|
|
|
|
- timeoutRef.current = setTimeout(
|
|
|
|
- () => reject(new Error("Operation timed out")),
|
|
|
|
- timeout,
|
|
|
|
- );
|
|
|
|
- });
|
|
|
|
- const requestWithTimeOutCancellation = async () => {
|
|
|
|
- const resp = await request;
|
|
|
|
- clearTimeout(timeoutRef.current);
|
|
|
|
- return resp;
|
|
|
|
- };
|
|
|
|
- return await Promise.race([
|
|
|
|
- requestWithTimeOutCancellation(),
|
|
|
|
- rejectOnTimeout,
|
|
|
|
- ]);
|
|
|
|
-};
|
|
|