Skip to content

Commit

Permalink
implement http api #980 #868
Browse files Browse the repository at this point in the history
- add closeCurrentFile action
- allow export confirm also via action
- upgrade electron
  • Loading branch information
mifi committed Oct 15, 2023
1 parent 97abcea commit a3cbce6
Show file tree
Hide file tree
Showing 10 changed files with 676 additions and 63 deletions.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ The main feature is lossless trimming and cutting of video and audio files, whic
- Black scene detection, silent audio detection, and scene change detection
- Divide timeline into segments with length L or into N segments or even randomized segments!
- Speed up / slow down video or audio file ([changing FPS](https:/mifi/lossless-cut/issues/1712))
- [Basic CLI support](cli.md)
- Basic [CLI](cli.md) and [HTTP API](api.md)

## Example lossless use cases

Expand Down Expand Up @@ -167,7 +167,7 @@ Unsupported files can still be converted to a supported format/codec from the `F

## [Import / export](import-export.md)

## [Command line interface (CLI) / API](cli.md)
## [Command line interface (CLI)](cli.md) & [HTTP API](api.md)

## [Known issues, limitations, troubleshooting, FAQ](issues.md)

Expand Down
48 changes: 48 additions & 0 deletions api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# 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.**

## Programmatically opening a file

This must be done with [the CLI](./cli.md).

## Enabling the API

```bash
LosslessCut --http-api
```

## API endpoints

### `POST /api/shortcuts/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).

#### [Available keyboard actions](./cli.md#available-keyboard-actions)

#### Example

Export the currently opened file:

```bash
curl -X POST http://localhost:8080/api/shortcuts/export
```

### Batch example

Start the main LosslessCut in one terminal with the HTTP API enabled:

```bash
LosslessCut --http-api
```

Then run the script in a different terminal:

```bash
for PROJECT in /path/to/folder/with/projects/*.llc
LosslessCut $PROJECT
sleep 5 # wait for the file to open
curl -X POST http://localhost:8080/api/shortcuts/export
curl -X POST http://localhost:8080/api/shortcuts/closeCurrentFile
done
```
44 changes: 29 additions & 15 deletions cli.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
# Command line interface (CLI)

LosslessCut has limited support for automation through the CLI. Note that these examples assume that you have set up LosslessCut in your `PATH` environment. Alternatively you can run it like this:
LosslessCut has basic support for automation through the CLI. See also [HTTP API](./api.md).

```bash
LosslessCut [options] [files]
```

Note that these examples assume that you have set up the LosslessCut executable to be available in your `PATH` (command line environment). Alternatively you can run it like this:

```bash
# First navigate to the folder containing the LosslessCut app
cd /path/to/directory/containing/app
# Then run it
# On Linux:
./LosslessCut arguments
# On Windows:
Expand All @@ -18,36 +26,42 @@ LosslessCut file1.mp4 file2.mkv
```

## Override settings (experimental)
See [available settings](https:/mifi/lossless-cut/blob/master/public/configStore.js). 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:
See [available settings](https:/mifi/lossless-cut/blob/master/public/configStore.js). 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}'
```

## Other options

- `--locales-path` Customise path to locales (useful for [translators](./translation.md)).
- `--disable-networking` Turn off all network requests.
- `--http-api` Start the [HTTP server with an API](./api.md) to control LosslessCut, optionally specifying a port (default `8080`).
- `--keyboard-action` Run a keyboard action (see below.)

## Controlling a running instance (experimental)

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. *Note that this is considered experimental and the API may change at any time.*
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.*

### Keyboard actions, `--keyboard-action`

Simulate a keyboard press action. The available action names can be found in the "Keyboard shortcuts" dialog (Note: you don't have to bind them to any key).
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.

### 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:

```bash
# Open a file in an already running instance
LosslessCut file.mp4
sleep 3 # hopefully the file has loaded by now
# Export the currently opened file
LosslessCut --keyboard-action export
```

#### Batch example

Note that there is no synchronization, and the action will exit immediately, regardless of how long the action takes. This means that you will have to sleep between multiple actions.
### Open files in running instance

```bash
for PROJECT in /path/to/folder/with/projects/*.llc
LosslessCut $PROJECT
sleep 5 # wait for the file to open
LosslessCut --keyboard-action export
sleep 10 # hopefully done by then
LosslessCut --keyboard-action quit
done
```
LosslessCut file1.mp4 file2.mkv
```
2 changes: 1 addition & 1 deletion issues.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
- **Can LosslessCut crop, resize, stretch, mirror, overlay text/images, watermark, blur, redact, re-encode, create GIF, slideshow, burn subtitles, color grading, fade/transition between video clips, fade/combine/mix/merge audio tracks or change audio volume?**
- No, these are all lossy operations (meaning you *have* to re-encode the file), but in the future I may start to implement such features. [See this issue for more information.](https:/mifi/lossless-cut/issues/372)
- Can LosslessCut be batched/automated using a CLI or API?
- While it was never designed for advanced batching/automation, it does have a [basic CLI](./cli.md), and there are a few feature requests regarding this: [#980](https:/mifi/lossless-cut/issues/980) [#868](https:/mifi/lossless-cut/issues/868).
- While it was never designed for advanced batching/automation, it does have a [basic CLI and a HTTP API](./cli.md), and there are a few feature requests regarding this: [#980](https:/mifi/lossless-cut/issues/980) [#868](https:/mifi/lossless-cut/issues/868).
- Is there a keyboard shortcut to do X?
- First check the Keyboard shortcuts dialog. If you cannot find your shortcut there, [see this issue.](https:/mifi/lossless-cut/issues/254)
- When will you implement feature X?
Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
"cross-env": "^7.0.3",
"csv-parse": "^4.15.3",
"csv-stringify": "^5.6.2",
"electron": "^26.2.4",
"electron": "^27.0.0",
"electron-builder": "^24.6.3",
"electron-builder-notarize": "^1.5.1",
"electron-devtools-installer": "^3.2.0",
Expand Down Expand Up @@ -105,6 +105,8 @@
"electron-store": "5.1.1",
"electron-unhandled": "^4.0.1",
"execa": "5",
"express": "^4.18.2",
"express-async-handler": "^1.2.0",
"file-type": "16",
"file-url": "^3.0.0",
"fs-extra": "^8.1.0",
Expand All @@ -114,6 +116,7 @@
"json5": "^2.2.2",
"lodash": "^4.17.19",
"mime-types": "^2.1.14",
"morgan": "^1.10.0",
"semver": "^7.5.2",
"string-to-stream": "^1.1.1",
"strtok3": "^6.0.0",
Expand Down
32 changes: 31 additions & 1 deletion public/electron.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const menu = require('./menu');
const configStore = require('./configStore');
const { frontendBuildDir } = require('./util');
const attachContextMenu = require('./contextMenu');
const HttpServer = require('./httpServer');

const { checkNewVersion } = require('./update-checker');

Expand Down Expand Up @@ -62,6 +63,22 @@ let disableNetworking;

const openFiles = (paths) => mainWindow.webContents.send('openFiles', paths);

let apiKeyboardActionRequestsId = 0;
const apiKeyboardActionRequests = new Map();

async function sendApiKeyboardAction(action) {
try {
const id = apiKeyboardActionRequestsId;
apiKeyboardActionRequestsId += 1;
mainWindow.webContents.send('apiKeyboardAction', { id, action });
await new Promise((resolve) => {
apiKeyboardActionRequests.set(id, resolve);
});
} catch (err) {
logger.error('sendApiKeyboardAction', err);
}
}

// https:/electron/electron/issues/526#issuecomment-563010533
function getSizeOptions() {
const bounds = configStore.get('windowBounds');
Expand Down Expand Up @@ -205,7 +222,7 @@ function initApp() {
logger.info('second-instance', argv2);

if (argv2._ && argv2._.length > 0) openFilesEventually(argv2._);
else if (argv2.keyboardAction) mainWindow.webContents.send('apiKeyboardAction', argv2.keyboardAction);
else if (argv2.keyboardAction) sendApiKeyboardAction(argv2.keyboardAction);
});

// Quit when all windows are closed.
Expand Down Expand Up @@ -249,6 +266,10 @@ function initApp() {
}
await shell.trashItem(path);
});

ipcMain.on('apiKeyboardActionResponse', (e, { id }) => {
apiKeyboardActionRequests.get(id)?.();
});
}


Expand Down Expand Up @@ -290,6 +311,15 @@ const readyPromise = app.whenReady();
});
}

const { httpApi } = argv;

if (httpApi != null) {
const port = typeof httpApi === 'number' ? httpApi : 8080;
const { startHttpServer } = HttpServer({ port, onKeyboardAction: sendApiKeyboardAction });
await startHttpServer();
}


if (isDev) {
const { default: installExtension, REACT_DEVELOPER_TOOLS } = require('electron-devtools-installer'); // eslint-disable-line global-require,import/no-extraneous-dependencies

Expand Down
50 changes: 50 additions & 0 deletions public/httpServer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
const express = require('express');
const morgan = require('morgan');
const http = require('http');
const asyncHandler = require('express-async-handler');
const { homepage } = require('./constants');


const logger = require('./logger');


module.exports = ({ port, onKeyboardAction }) => {
const app = express();

// https://expressjs.com/en/resources/middleware/morgan.html
const morganFormat = ':remote-addr :method :url HTTP/:http-version :status - :response-time ms';
// https://stackoverflow.com/questions/27906551/node-js-logging-use-morgan-and-winston
app.use(morgan(morganFormat, {
stream: { write: (message) => logger.info(message.trim()) },
}));

const apiRouter = express.Router();

app.get('/', (req, res) => res.send(`See ${homepage}`));

app.use('/api', apiRouter);

apiRouter.post('/shortcuts/:action', express.json(), asyncHandler(async (req, res) => {
await onKeyboardAction(req.params.action);
res.end();
}));

const server = http.createServer(app);

server.on('error', (err) => logger.error('http server error', err));

const startHttpServer = async () => new Promise((resolve, reject) => {
// force ipv4
const host = '127.0.0.1';
server.listen(port, host, () => {
logger.info('HTTP API listening on', `http://${host}:${port}/`);
resolve();
});

server.once('error', reject);
});

return {
startHttpServer,
};
};
45 changes: 28 additions & 17 deletions src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -1139,6 +1139,15 @@ const App = memo(() => {
return;
}

if (segmentsToExport.length < 1) {
return;
}

if (haveInvalidSegs) {
errorToast(i18n.t('Start time must be before end time'));
return;
}

setStreamsSelectorShown(false);
setExportConfirmVisible(false);

Expand Down Expand Up @@ -1267,19 +1276,17 @@ const App = memo(() => {
setWorking();
setCutProgress();
}
}, [numStreamsToCopy, setWorking, segmentsToChaptersOnly, outSegTemplateOrDefault, generateOutSegFileNames, segmentsToExport, getOutSegError, cutMultiple, outputDir, customOutDir, fileFormat, duration, isRotationSet, effectiveRotation, copyFileStreams, allFilesMeta, keyframeCut, shortestFlag, ffmpegExperimental, preserveMovData, preserveMetadataOnMerge, movFastStart, avoidNegativeTs, customTagsByFile, paramsByStreamId, detectedFps, willMerge, enableOverwriteOutput, exportConfirmEnabled, mainFileFormatData, mainStreams, exportExtraStreams, areWeCutting, mergedOutFilePath, hideAllNotifications, cleanupChoices, cleanupFilesWithDialog, selectedSegmentsOrInverse, segmentsToChapters, invertCutSegments, autoConcatCutSegments, autoDeleteMergedSegments, nonCopiedExtraStreams, filePath, handleExportFailed]);
}, [numStreamsToCopy, segmentsToExport, haveInvalidSegs, setWorking, segmentsToChaptersOnly, outSegTemplateOrDefault, generateOutSegFileNames, getOutSegError, cutMultiple, outputDir, customOutDir, fileFormat, duration, isRotationSet, effectiveRotation, copyFileStreams, allFilesMeta, keyframeCut, shortestFlag, ffmpegExperimental, preserveMovData, preserveMetadataOnMerge, movFastStart, avoidNegativeTs, customTagsByFile, paramsByStreamId, detectedFps, willMerge, enableOverwriteOutput, exportConfirmEnabled, mainFileFormatData, mainStreams, exportExtraStreams, areWeCutting, mergedOutFilePath, hideAllNotifications, cleanupChoices.cleanupAfterExport, cleanupFilesWithDialog, selectedSegmentsOrInverse, segmentsToChapters, invertCutSegments, autoConcatCutSegments, autoDeleteMergedSegments, nonCopiedExtraStreams, filePath, handleExportFailed]);

const onExportPress = useCallback(async () => {
if (!filePath || workingRef.current || segmentsToExport.length < 1) return;
if (!filePath) return;

if (haveInvalidSegs) {
errorToast(i18n.t('Start time must be before end time'));
return;
if (!exportConfirmEnabled || exportConfirmVisible) {
await onExportConfirm();
} else {
setExportConfirmVisible(true);
}

if (exportConfirmEnabled) setExportConfirmVisible(true);
else await onExportConfirm();
}, [filePath, haveInvalidSegs, segmentsToExport, exportConfirmEnabled, onExportConfirm]);
}, [filePath, exportConfirmEnabled, exportConfirmVisible, onExportConfirm]);

const captureSnapshot = useCallback(async () => {
if (!filePath) return;
Expand Down Expand Up @@ -1997,10 +2004,11 @@ const App = memo(() => {
copySegmentsToClipboard,
reloadFile: () => setCacheBuster((v) => v + 1),
quit: () => quitApp(),
closeCurrentFile: () => { closeFileWithConfirm(); },
};

return mainActions[action];
}, [addSegment, alignSegmentTimesToKeyframes, askSetStartTimeOffset, batchFileJump, batchOpenSelectedFile, captureSnapshot, captureSnapshotAsCoverArt, changePlaybackRate, cleanupFilesDialog, clearSegments, closeBatch, combineOverlappingSegments, combineSelectedSegments, concatCurrentBatch, convertFormatBatch, copySegmentsToClipboard, createFixedDurationSegments, createNumSegments, createRandomSegments, currentSegIndexSafe, cutSegmentsHistory, deselectAllSegments, duplicateCurrentSegment, extractAllStreams, extractCurrentSegmentFramesAsImages, extractSelectedSegmentsFramesAsImages, fillSegmentsGaps, goToTimecode, increaseRotation, invertAllSegments, invertSelectedSegments, jumpCutEnd, jumpCutStart, jumpSeg, jumpTimelineEnd, jumpTimelineStart, keyboardNormalSeekSpeed, keyboardSeekAccFactor, onExportPress, onLabelSegment, pause, play, removeCutSegment, removeSelectedSegments, reorderSegsByStartTime, seekClosestKeyframe, seekRel, seekRelPercent, selectAllSegments, selectOnlyCurrentSegment, setCutEnd, setCutStart, setPlaybackVolume, shiftAllSegmentTimes, shortStep, shuffleSegments, splitCurrentSegment, timelineToggleComfortZoom, toggleCaptureFormat, toggleCurrentSegmentSelected, toggleKeyframeCut, toggleLastCommands, toggleLoopSelectedSegments, togglePlay, toggleSegmentsList, toggleStreamsSelector, toggleStripAudio, tryFixInvalidDuration, userHtml5ifyCurrentFile, zoomRel]);
}, [addSegment, alignSegmentTimesToKeyframes, askSetStartTimeOffset, batchFileJump, batchOpenSelectedFile, captureSnapshot, captureSnapshotAsCoverArt, changePlaybackRate, cleanupFilesDialog, clearSegments, closeBatch, closeFileWithConfirm, combineOverlappingSegments, combineSelectedSegments, concatCurrentBatch, convertFormatBatch, copySegmentsToClipboard, createFixedDurationSegments, createNumSegments, createRandomSegments, currentSegIndexSafe, cutSegmentsHistory, deselectAllSegments, duplicateCurrentSegment, extractAllStreams, extractCurrentSegmentFramesAsImages, extractSelectedSegmentsFramesAsImages, fillSegmentsGaps, goToTimecode, increaseRotation, invertAllSegments, invertSelectedSegments, jumpCutEnd, jumpCutStart, jumpSeg, jumpTimelineEnd, jumpTimelineStart, keyboardNormalSeekSpeed, keyboardSeekAccFactor, onExportPress, onLabelSegment, pause, play, removeCutSegment, removeSelectedSegments, reorderSegsByStartTime, seekClosestKeyframe, seekRel, seekRelPercent, selectAllSegments, selectOnlyCurrentSegment, setCutEnd, setCutStart, setPlaybackVolume, shiftAllSegmentTimes, shortStep, shuffleSegments, splitCurrentSegment, timelineToggleComfortZoom, toggleCaptureFormat, toggleCurrentSegmentSelected, toggleKeyframeCut, toggleLastCommands, toggleLoopSelectedSegments, togglePlay, toggleSegmentsList, toggleStreamsSelector, toggleStripAudio, tryFixInvalidDuration, userHtml5ifyCurrentFile, zoomRel]);

const onKeyPress = useCallback(({ action, keyup }) => {
function tryMainActions() {
Expand Down Expand Up @@ -2153,18 +2161,21 @@ const App = memo(() => {
}
}

function tryKeyboardAction(action) {
const fn = getKeyboardAction({ action });
if (!fn) {
console.error('Action not found:', action);
return;
async function tryApiKeyboardAction(event, { id, action }) {
console.log('API keyboard action:', action);
try {
const fn = getKeyboardAction({ action });
if (!fn) throw new Error(`Action not found: ${action}`);
await fn();
} finally {
// todo correlation ids
event.sender.send('apiKeyboardActionResponse', { id });
}
fn();
}

const actions = {
openFiles: (event, filePaths) => { userOpenFiles(filePaths.map(resolvePathIfNeeded)); },
apiKeyboardAction: (event, action) => tryKeyboardAction(action),
apiKeyboardAction: tryApiKeyboardAction,
openFilesDialog,
closeCurrentFile: () => { closeFileWithConfirm(); },
closeBatchFiles: () => { closeBatch(); },
Expand Down
4 changes: 4 additions & 0 deletions src/components/KeyboardShortcuts.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,10 @@ const KeyboardShortcuts = memo(({
name: t('Close current screen'),
category: otherCategory,
},
closeCurrentFile: {
name: t('Close current file'),
category: otherCategory,
},
quit: {
name: t('Quit LosslessCut'),
category: otherCategory,
Expand Down
Loading

0 comments on commit a3cbce6

Please sign in to comment.