implement events `export-start` and `export-complete`

see api.md for docs

also:
- allow passing arguments when using CLI to pass to second instance
- allow opening files from API
- improve docs

#980 #974 #1347
pull/2782/head
Mikael Finstad 4 months ago
parent 26022732b6
commit 2602273393
No known key found for this signature in database
GPG Key ID: 25AB36E3E81CBC26

@ -0,0 +1,26 @@
diff --git a/package.json b/package.json
index e51397cb4104da5424936208509d2aa0dc73313f..6f35005d143a4f52b8acd99ca0979ca573d79491 100644
--- a/package.json
+++ b/package.json
@@ -8,12 +8,16 @@
"umd:main": "dist/mitt.umd.js",
"source": "src/index.ts",
"typings": "index.d.ts",
+ "type": "module",
"exports": {
- "types": "./index.d.ts",
- "module": "./dist/mitt.mjs",
- "import": "./dist/mitt.mjs",
- "require": "./dist/mitt.js",
- "default": "./dist/mitt.mjs"
+ "import": {
+ "types": "./index.d.ts",
+ "default": "./dist/mitt.mjs"
+ },
+ "require": {
+ "types": "./index.d.ts",
+ "default": "./dist/mitt.js"
+ }
},
"scripts": {
"test": "npm-run-all --silent typecheck lint mocha test-types",

@ -1,29 +1,26 @@
# HTTP API 🧪
LosslessCut can be controlled via a HTTP API, if it is being run with the command line option `--http-api`. See also [CLI](cli.md). **Note that the HTTP API is experimental and may change at any time.**
LosslessCut can be controlled via a HTTP API, if it is being run with the command line option `--http-api`. **Note that the HTTP API is experimental and may change at any time.**
## Programmatically opening a file
This must be done with [the CLI](cli.md).
See also [CLI](cli.md).
## Enabling the API
To enable the API, run LosslessCut from the command line with this flag:
```bash
LosslessCut --http-api
```
## API endpoints
### `POST /api/action/:action`
## Action endpoint: `POST /api/action/:action`
Execute a keyboard shortcut `action`, similar to the `--keyboard-action` CLI option. This is different from the CLI in that most of the actions will wait for the action to finish before responding to the HTTP request (but not all).
Execute a keyboard shortcut `action`, similar to the `--keyboard-action` CLI option. This is different from the CLI in that most of the actions (but not all) will wait for the action to finish before responding to the HTTP request.
#### [Available keyboard actions](cli.md#available-keyboard-actions)
See [Available keyboard actions](cli.md#available-keyboard-actions).
#### Example
### Example actions
Export the currently opened file:
```bash
curl -X POST http://localhost:8080/api/action/export
```
@ -33,6 +30,10 @@ Seek to time:
curl -X POST http://localhost:8080/api/action/goToTimecodeDirect --json '{"time": "09:11"}'
```
Open one or more files:
```bash
curl -X POST http://localhost:8080/api/action/openFiles --json '["/path/to/file.mp4"]'
```
### Batch example
@ -52,3 +53,24 @@ for PROJECT in /path/to/folder/with/projects/*.llc
curl -X POST http://localhost:8080/api/action/closeCurrentFile
done
```
## Await event endpoint
This special endpoint allows you to wait for a certain event to occur in the app. The endpoint will hang and respond/return only when the event fires. Below are supported events.
### Event: `export-start`
Emitted when the export operation starts. The endpoint will return JSON `{ path: string }`.
### Event: `export-complete`
Emitted when the export operation completes (either success or failure). If successful, the endpoint will return JSON `{ paths: string[] }`.
#### Examples
Run a command after each file that has completed exporting:
```bash
while true; do
echo 'Do something with exported file path:' $(curl -s -X POST http://localhost:8080/api/await-event/export-complete | jq -r '.paths[0]')
done
```

@ -28,6 +28,7 @@ LosslessCut file1.mp4 file2.mkv
```
## Override settings (experimental) 🧪
See [available settings](https://github.com/mifi/lossless-cut/blob/master/src/main/configStore.ts). Note that this is subject to change in newer versions. ⚠️ If you specify incorrect values it could corrupt your configuration file. You may use JSON or JSON5. Example:
```bash
LosslessCut --settings-json '{captureFormat:"jpeg", "keyframeCut":true}'
@ -45,16 +46,23 @@ LosslessCut --settings-json '{captureFormat:"jpeg", "keyframeCut":true}'
If you have the "Allow multiple instances" setting enabled, you can control a running instance of LosslessCut from the outside, using for example a command line. You do this by issuing messages to it through the `LosslessCut` command. Currently only keyboard actions are supported, and you can open files. *Note that this is considered experimental and the API may change at any time.*
### Open files in running instance
```bash
LosslessCut file1.mp4 file2.mkv
```
### Keyboard actions, `--keyboard-action`
Simulate a keyboard press action in an already running instance of LosslessCut. Note that the command will return immediately, so if you want to run multiple actions in a sequence, you have to `sleep` for a few seconds between the commands. Alternatively if you want to wait until an action has finished processing, you can use the [HTTP API](api.md) instead. Note that the HTTP API does not support opening files, and it is currently not possible to wait for a file to have finished opening.
Simulate a keyboard press action in an already running instance of LosslessCut. **Note that the command will return immediately**, so if you want to run multiple actions in a sequence, you have to `sleep` for a few seconds between the commands. Alternatively if you want to wait until an action has finished processing, use the [HTTP API](api.md) instead.
### Available keyboard actions
A list of the available action names can be found in the "Keyboard shortcuts" dialog in the app. Note that you don't have to bind them to any key before using them.
Example:
#### Example keyboard actions
Open a file and export it:
```bash
# Open a file in an already running instance
LosslessCut file.mp4
@ -63,8 +71,11 @@ sleep 3 # hopefully the file has loaded by now
LosslessCut --keyboard-action export
```
### Open files in running instance
Seek to specific time in the media:
```bash
LosslessCut file1.mp4 file2.mkv
LosslessCut --keyboard-action goToTimecodeDirect '{"time": "12.23"}'
```
### HTTP API
You can also control a running instance with the [HTTP API](api.md).

@ -106,7 +106,6 @@
"leaflet": "^1.9.4",
"lodash": "^4.17.23",
"luxon": "^3.7.2",
"mitt": "^3.0.1",
"mkdirp": "^3.0.1",
"motion": "^12.24.7",
"nanoid": "^5.1.6",
@ -154,6 +153,7 @@
"json5": "^2.2.3",
"lodash.debounce": "^4.0.8",
"mime-types": "^3.0.2",
"mitt": "patch:mitt@npm%3A3.0.1#~/.yarn/patches/mitt-npm-3.0.1-ce290ffa77.patch",
"morgan": "^1.10.1",
"semver": "^7.7.3",
"string-to-stream": "^3.0.1",

@ -6,10 +6,13 @@ import assert from 'node:assert';
import { homepageUrl } from '../common/constants.js';
import logger from './logger.js';
import type { AppEvent } from './index.js';
export default ({ port, onKeyboardAction }: {
port: number, onKeyboardAction: (action: string, args: unknown[]) => Promise<void>,
export default ({ port, onKeyboardAction, onAwaitAppEvent }: {
port: number,
onKeyboardAction: (action: string, args: unknown[]) => Promise<void>,
onAwaitAppEvent: (eventName: string, signal: AbortSignal) => Promise<AppEvent>,
}) => {
const app = express();
@ -35,6 +38,15 @@ export default ({ port, onKeyboardAction }: {
res.end();
}));
apiRouter.post('/await-event/:eventName', express.json(), asyncHandler(async (req, res) => {
const { eventName } = req.params;
assert(eventName != null);
const abortController = new AbortController();
abortController.signal.addEventListener('abort', () => logger.info('await-event aborted', eventName));
req.on('close', () => abortController.abort());
res.json(await onAwaitAppEvent(eventName, abortController.signal));
}));
const server = http.createServer(app);
server.on('error', (err) => logger.error('http server error', err));

@ -19,6 +19,7 @@ import { fileTypeFromFile } from 'file-type/node';
import type { Asyncify } from 'type-fest';
// eslint-disable-next-line import/no-extraneous-dependencies
import { installExtension, REACT_DEVELOPER_TOOLS } from 'electron-devtools-installer';
import mitt from 'mitt';
import logger from './logger.js';
import menu from './menu.js';
@ -79,14 +80,42 @@ async function sendApiAction(action: string, args?: unknown[]) {
const id = apiActionRequestsId;
apiActionRequestsId += 1;
mainWindow!.webContents.send('apiAction', { id, action, args } satisfies ApiActionRequest);
await new Promise<void>((resolve) => {
apiActionRequests.set(id, resolve);
});
await new Promise<void>((resolve) => apiActionRequests.set(id, resolve));
} catch (err) {
logger.error('sendApiAction', err);
}
}
export type AppEvent = {
eventName: 'export-complete',
paths?: string[],
} | {
eventName: 'export-start',
path: string,
}
const appEventEmitter = mitt<{ appEvent: AppEvent }>();
ipcMain.on('appEvent', (_e, appEvent: AppEvent) => {
appEventEmitter.emit('appEvent', appEvent);
});
async function onAwaitAppEvent(awaitEventName: string, signal: AbortSignal) {
return new Promise<AppEvent>((resolve, reject) => {
const handler = (appEvent: AppEvent) => {
if (appEvent.eventName === awaitEventName) {
appEventEmitter.off('appEvent', handler);
resolve(appEvent);
}
};
appEventEmitter.on('appEvent', handler);
signal.addEventListener('abort', () => {
appEventEmitter.off('appEvent', handler);
reject(new Error('Aborted'));
});
});
}
// https://github.com/electron/electron/issues/526#issuecomment-563010533
function getSavedBounds() {
const bounds = configStore.get('windowBounds');
@ -283,8 +312,8 @@ async function init() {
logger.info('second-instance', argv2);
if (argv2._ && argv2._.length > 0) openFilesEventually(argv2._.map(String));
else if (argv2['keyboardAction']) sendApiAction(argv2['keyboardAction']);
if (argv2['keyboardAction']) sendApiAction(argv2['keyboardAction'], argv2._.map((arg) => JSON.parse(String(arg))));
else if (argv2._ && argv2._.length > 0) openFilesEventually(argv2._.map(String));
});
// Quit when all windows are closed.
@ -355,7 +384,7 @@ async function init() {
if (httpApi != null) {
const port = typeof httpApi === 'number' ? httpApi : 8080;
const { startHttpServer } = HttpServer({ port, onKeyboardAction: sendApiAction });
const { startHttpServer } = HttpServer({ port, onKeyboardAction: sendApiAction, onAwaitAppEvent });
await startHttpServer();
logger.info('HTTP API listening on port', port);
}
@ -452,7 +481,6 @@ app.addListener('remote-require', (event: { returnValue: RemoteApiLegacy }, _web
}
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ipcMain.handle('__electron_rpc__', async (_event, method: keyof RemoteApi, args: any[]) => {
const fn = remoteApi[method];

@ -9,7 +9,6 @@ import { produce } from 'immer';
import screenfull from 'screenfull';
import type { IpcRendererEvent } from 'electron';
import { IoMdMenu } from 'react-icons/io';
import fromPairs from 'lodash/fromPairs';
import sum from 'lodash/sum';
import invariant from 'tiny-invariant';
@ -115,6 +114,7 @@ import GenericDialog, { useDialog } from './components/GenericDialog';
import useHtml5ify from './hooks/useHtml5ify';
import WhatsNew from './components/WhatsNew';
import mainApi from './mainApi.js';
import type { AppEvent } from '../../main/index.js';
const electron = window.require('electron');
const { lstat } = window.require('fs/promises');
@ -127,6 +127,10 @@ const hevcPlaybackSupportedPromise = doesPlayerSupportHevcPlayback();
// eslint-disable-next-line unicorn/prefer-top-level-await
hevcPlaybackSupportedPromise.catch((err) => console.error(err));
function emitEvent(appEvent: AppEvent) {
electron.ipcRenderer.send('appEvent', appEvent satisfies AppEvent);
}
function App() {
const { t } = useTranslation();
@ -221,7 +225,6 @@ function App() {
electron.ipcRenderer.send('setLanguage', language);
}, [language]);
const isFileOpened = !!filePath;
const onOutputFormatUserChange = useCallback((newFormat: string) => {
@ -1023,6 +1026,7 @@ function App() {
const onExportConfirm = useCallback(async () => {
invariant(filePath != null && outputDir != null);
emitEvent({ eventName: 'export-start', path: filePath });
if (numStreamsToCopy === 0) {
errorToast(i18n.t('No tracks selected for export'));
@ -1174,7 +1178,9 @@ function App() {
}
// Note: this should be after cleanup, so we don't accidentally open two dialogs at the same time, leading to error *and* success dialog simultaneously https://github.com/mifi/lossless-cut/issues/2609
const revealPath = willMerge && mergedOutFilePath != null ? mergedOutFilePath : outFiles[0]!.path;
const exportedPaths = willMerge && mergedOutFilePath != null ? [mergedOutFilePath] : outFiles.map((f) => f.path);
const [revealPath] = exportedPaths;
invariant(revealPath != null);
if (!hideAllNotifications) {
showOsNotification(i18n.t('Export finished'));
openCutFinishedDialog({ filePath: revealPath, warnings: [...warnings], notices: [...notices] });
@ -1182,7 +1188,11 @@ function App() {
setExportCount((c) => c + 1);
setCurrentFileExportCount((c) => c + 1);
emitEvent({ eventName: 'export-complete', paths: exportedPaths });
} catch (err) {
emitEvent({ eventName: 'export-complete' });
if (isAbortedError(err)) return;
showOsNotification(i18n.t('Failed to export'));
@ -2245,7 +2255,7 @@ function App() {
}, [checkFileOpened, detectedFps, fileDuration, loadCutSegments, withErrorHandling]);
useEffect(() => {
const openFiles = (filePaths: string[]) => { userOpenFiles(filePaths.map((p) => resolvePathIfNeeded(p))); };
const openFiles = async (filePaths: string[]) => userOpenFiles(filePaths.map((p) => resolvePathIfNeeded(p)));
async function actionWithCatch(fn: () => Promise<void>) {
try {
@ -2283,7 +2293,6 @@ function App() {
// all main actions (no arguments):
...Object.entries(mainActions).map(([key, fn]) => [
key,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async () => {
fn();
},

@ -17,6 +17,9 @@ export type OpenFilesActionArgs = z.infer<typeof openFilesActionArgsSchema>
export const goToTimecodeDirectArgsSchema = z.tuple([z.object({ time: z.string() })]);
export type GoToTimecodeDirectArgs = z.infer<typeof goToTimecodeDirectArgsSchema>
export const awaitEventArgsSchema = z.tuple([z.object({ eventName: z.string() })]);
export type AwaitEventArgs = z.infer<typeof awaitEventArgsSchema>;
export const segmentTagsSchema = z.record(z.string(), z.string());
export type SegmentTags = z.infer<typeof segmentTagsSchema>

@ -9387,7 +9387,7 @@ __metadata:
lodash.debounce: "npm:^4.0.8"
luxon: "npm:^3.7.2"
mime-types: "npm:^3.0.2"
mitt: "npm:^3.0.1"
mitt: "patch:mitt@npm%3A3.0.1#~/.yarn/patches/mitt-npm-3.0.1-ce290ffa77.patch"
mkdirp: "npm:^3.0.1"
morgan: "npm:^1.10.1"
motion: "npm:^12.24.7"
@ -10198,13 +10198,20 @@ __metadata:
languageName: node
linkType: hard
"mitt@npm:^3.0.1":
"mitt@npm:3.0.1":
version: 3.0.1
resolution: "mitt@npm:3.0.1"
checksum: 10/287c70d8e73ffc25624261a4989c783768aed95ecb60900f051d180cf83e311e3e59865bfd6e9d029cdb149dc20ba2f128a805e9429c5c4ce33b1416c65bbd14
languageName: node
linkType: hard
"mitt@patch:mitt@npm%3A3.0.1#~/.yarn/patches/mitt-npm-3.0.1-ce290ffa77.patch":
version: 3.0.1
resolution: "mitt@patch:mitt@npm%3A3.0.1#~/.yarn/patches/mitt-npm-3.0.1-ce290ffa77.patch::version=3.0.1&hash=19c9d7"
checksum: 10/2d150dd003cf6e039fe54d18d276646c475c2b0ba6ae8bb4df446547c910ad080c71d1aa7ab484145ae2671bbb26dea5efbed2004d7852a93b9e127b7a0f0814
languageName: node
linkType: hard
"mkdirp@npm:^0.5.1":
version: 0.5.5
resolution: "mkdirp@npm:0.5.5"

Loading…
Cancel
Save