1
0
mirror of https://github.com/mifi/lossless-cut.git synced 2024-11-21 09:52:33 +01:00

allow passing arguments to api actions

also rename shortcuts to actions in api (breaking)

closes #2087
This commit is contained in:
Mikael Finstad 2024-08-05 01:22:34 +02:00
parent e5f23510e1
commit 533074ddae
No known key found for this signature in database
GPG Key ID: 25AB36E3E81CBC26
6 changed files with 115 additions and 60 deletions

14
api.md
View File

@ -14,7 +14,7 @@ LosslessCut --http-api
## API endpoints
### `POST /api/shortcuts/action`
### `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).
@ -25,9 +25,15 @@ Execute a keyboard shortcut `action`, similar to the `--keyboard-action` CLI opt
Export the currently opened file:
```bash
curl -X POST http://localhost:8080/api/shortcuts/export
curl -X POST http://localhost:8080/api/action/export
```
Seek to time:
```bash
curl -X POST http://localhost:8080/api/action/goToTimecodeDirect --json '{"time": "09:11"}'
```
### Batch example
Start the main LosslessCut in one terminal with the HTTP API enabled:
@ -42,7 +48,7 @@ Then run the script in a different terminal:
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
curl -X POST http://localhost:8080/api/action/export
curl -X POST http://localhost:8080/api/action/closeCurrentFile
done
```

View File

@ -9,7 +9,7 @@ import logger from './logger.js';
export default ({ port, onKeyboardAction }: {
port: number, onKeyboardAction: (a: string) => Promise<void>,
port: number, onKeyboardAction: (action: string, args: unknown[]) => Promise<void>,
}) => {
const app = express();
@ -26,11 +26,12 @@ export default ({ port, onKeyboardAction }: {
app.use('/api', apiRouter);
apiRouter.post('/shortcuts/:action', express.json(), asyncHandler(async (req, res) => {
apiRouter.post('/action/:action', express.json(), asyncHandler(async (req, res) => {
// eslint-disable-next-line prefer-destructuring
const action = req.params['action'];
const parameters = req.body as unknown;
assert(action != null);
await onKeyboardAction(action);
await onKeyboardAction(action, [parameters]);
res.end();
}));

View File

@ -27,7 +27,7 @@ import { checkNewVersion } from './updateChecker.js';
import * as i18nCommon from './i18nCommon.js';
import './i18n.js';
import { ApiKeyboardActionRequest } from '../../types.js';
import { ApiActionRequest } from '../../types.js';
export * as ffmpeg from './ffmpeg.js';
@ -107,19 +107,19 @@ let disableNetworking: boolean;
const openFiles = (paths: string[]) => mainWindow!.webContents.send('openFiles', paths);
let apiKeyboardActionRequestsId = 0;
const apiKeyboardActionRequests = new Map<number, () => void>();
let apiActionRequestsId = 0;
const apiActionRequests = new Map<number, () => void>();
async function sendApiKeyboardAction(action: string) {
async function sendApiAction(action: string, args?: unknown[]) {
try {
const id = apiKeyboardActionRequestsId;
apiKeyboardActionRequestsId += 1;
mainWindow!.webContents.send('apiKeyboardAction', { id, action } satisfies ApiKeyboardActionRequest);
const id = apiActionRequestsId;
apiActionRequestsId += 1;
mainWindow!.webContents.send('apiAction', { id, action, args } satisfies ApiActionRequest);
await new Promise<void>((resolve) => {
apiKeyboardActionRequests.set(id, resolve);
apiActionRequests.set(id, resolve);
});
} catch (err) {
logger.error('sendApiKeyboardAction', err);
logger.error('sendApiAction', err);
}
}
@ -269,7 +269,7 @@ function initApp() {
logger.info('second-instance', argv2);
if (argv2._ && argv2._.length > 0) openFilesEventually(argv2._.map(String));
else if (argv2['keyboardAction']) sendApiKeyboardAction(argv2['keyboardAction']);
else if (argv2['keyboardAction']) sendApiAction(argv2['keyboardAction']);
});
// Quit when all windows are closed.
@ -317,8 +317,8 @@ function initApp() {
ipcMain.handle('showItemInFolder', (_e, path) => shell.showItemInFolder(path));
ipcMain.on('apiKeyboardActionResponse', (_e, { id }) => {
apiKeyboardActionRequests.get(id)?.();
ipcMain.on('apiActionResponse', (_e, { id }) => {
apiActionRequests.get(id)?.();
});
}
@ -367,7 +367,7 @@ const readyPromise = app.whenReady();
if (httpApi != null) {
const port = typeof httpApi === 'number' ? httpApi : 8080;
const { startHttpServer } = HttpServer({ port, onKeyboardAction: sendApiKeyboardAction });
const { startHttpServer } = HttpServer({ port, onKeyboardAction: sendApiAction });
await startHttpServer();
logger.info('HTTP API listening on port', port);
}

View File

@ -9,6 +9,7 @@ import i18n from 'i18next';
import { useTranslation } from 'react-i18next';
import { produce } from 'immer';
import screenfull from 'screenfull';
import { IpcRendererEvent } from 'electron';
import fromPairs from 'lodash/fromPairs';
import sortBy from 'lodash/sortBy';
@ -87,8 +88,8 @@ import { rightBarWidth, leftBarWidth, ffmpegExtractWindow, zoomMax } from './uti
import BigWaveform from './components/BigWaveform';
import isDev from './isDev';
import { Chapter, ChromiumHTMLVideoElement, CustomTagsByFile, EdlExportType, EdlFileType, EdlImportType, FfmpegCommandLog, FilesMeta, FormatTimecode, ParamsByStreamId, ParseTimecode, PlaybackMode, SegmentColorIndex, SegmentTags, SegmentToExport, StateSegment, Thumbnail, TunerType } from './types';
import { CaptureFormat, KeyboardAction, Html5ifyMode, WaveformMode } from '../../../types';
import { Chapter, ChromiumHTMLVideoElement, CustomTagsByFile, EdlExportType, EdlFileType, EdlImportType, FfmpegCommandLog, FilesMeta, FormatTimecode, goToTimecodeDirectArgsSchema, openFilesActionArgsSchema, ParamsByStreamId, ParseTimecode, PlaybackMode, SegmentColorIndex, SegmentTags, SegmentToExport, StateSegment, Thumbnail, TunerType } from './types';
import { CaptureFormat, KeyboardAction, Html5ifyMode, WaveformMode, ApiActionRequest } from '../../../types';
import { FFprobeChapter, FFprobeFormat, FFprobeStream } from '../../../ffprobe';
const electron = window.require('electron');
@ -1734,18 +1735,26 @@ function App() {
const goToTimecode = useCallback(async () => {
if (!filePath) return;
const timeCode = await promptTimeOffset({
const timecode = await promptTimeOffset({
initialValue: formatTimecode({ seconds: commandedTimeRef.current }),
title: i18n.t('Seek to timecode'),
inputPlaceholder: timecodePlaceholder,
parseTimecode,
});
if (timeCode === undefined) return;
if (timecode === undefined) return;
userSeekAbs(timeCode);
userSeekAbs(timecode);
}, [filePath, formatTimecode, parseTimecode, timecodePlaceholder, userSeekAbs]);
const goToTimecodeDirect = useCallback(async ({ time: timeStr }: { time: string }) => {
if (!filePath) return;
invariant(timeStr != null);
const timecode = parseTimecode(timeStr);
invariant(timecode != null);
userSeekAbs(timecode);
}, [filePath, parseTimecode, userSeekAbs]);
const toggleStreamsSelector = useCallback(() => setStreamsSelectorShown((v) => !v), []);
const handleShowStreamsSelectorClick = useCallback(() => {
@ -2099,7 +2108,7 @@ function App() {
onEditSegmentTags(currentSegIndexSafe);
}, [currentSegIndexSafe, onEditSegmentTags]);
type MainKeyboardAction = Exclude<KeyboardAction, 'closeActiveScreen' | 'toggleKeyboardShortcuts'>;
type MainKeyboardAction = Exclude<KeyboardAction, 'closeActiveScreen' | 'toggleKeyboardShortcuts' | 'goToTimecodeDirect'>;
const mainActions = useMemo(() => {
async function exportYouTube() {
@ -2403,28 +2412,7 @@ function App() {
}
}
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({ keyup: false });
} catch (err) {
handleError(err);
} finally {
// todo correlation ids
event.sender.send('apiKeyboardActionResponse', { id });
}
}
// todo
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const actionsWithArgs: Record<string, (...args: any[]) => void> = {
openFiles: (filePaths: string[]) => { userOpenFiles(filePaths.map((p) => resolvePathIfNeeded(p))); },
// todo separate actions per type and move them into mainActions? https://github.com/mifi/lossless-cut/issues/254#issuecomment-932649424
importEdlFile,
exportEdlFile: tryExportEdlFile,
};
const openFiles = (filePaths: string[]) => { userOpenFiles(filePaths.map((p) => resolvePathIfNeeded(p))); };
async function actionWithCatch(fn: () => void) {
try {
@ -2434,30 +2422,83 @@ function App() {
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const actionsWithCatch: Readonly<[string, (event: unknown, ...a: any) => Promise<void>]>[] = [
const allActions = [
// actions with arguments:
...Object.entries(actionsWithArgs).map(([key, fn]) => [
[
'openFiles',
async (...argsRaw: unknown[]) => {
await openFiles(...openFilesActionArgsSchema.parse(argsRaw));
},
] as const,
[
'goToTimecodeDirect',
async (...argsRaw: unknown[]) => {
await goToTimecodeDirect(...goToTimecodeDirectArgsSchema.parse(argsRaw));
},
] as const,
...Object.entries({
// todo separate actions per type and move them into mainActions? https://github.com/mifi/lossless-cut/issues/254#issuecomment-932649424
importEdlFile,
exportEdlFile: tryExportEdlFile,
}).map(([key, fn]) => [
key,
async (_event: unknown, ...args: unknown[]) => actionWithCatch(() => fn(...args)),
async (...args: unknown[]) => {
await (fn as (...args2: unknown[]) => Promise<void>)(...args);
},
] as const),
// all main actions (no arguments, so simulate keyup):
...Object.entries(mainActions).map(([key, fn]) => [
key,
async () => actionWithCatch(() => fn({ keyup: true })),
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async () => {
fn({ keyup: true });
},
] as const),
// also called from menu:
['toggleKeyboardShortcuts', async () => actionWithCatch(() => toggleKeyboardShortcuts())],
[
'toggleKeyboardShortcuts',
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async () => {
toggleKeyboardShortcuts();
},
] as const,
];
actionsWithCatch.forEach(([key, action]) => electron.ipcRenderer.on(key, action));
electron.ipcRenderer.on('apiKeyboardAction', tryApiKeyboardAction);
const allActionsMap = Object.fromEntries(allActions);
const actionsWithCatch = allActions.map(([key, fn]) => [
key,
(...args: Parameters<typeof fn>) => actionWithCatch(() => fn(...args)),
] as const);
async function tryApiAction(event: IpcRendererEvent, { id, action, args }: ApiActionRequest) {
console.log('API action:', action, args);
try {
const fn = allActionsMap[action];
if (!fn) throw new Error(`Action not found: ${action}`);
// todo validate arguments
await (args != null ? fn(...args) : fn());
} catch (err) {
handleError(err);
} finally {
// todo correlation ids
event.sender.send('apiActionResponse', { id });
}
}
const ipcActions = actionsWithCatch.map(([key, fn]) => [
key,
(_event: IpcRendererEvent, ...args: Parameters<typeof fn>) => actionWithCatch(() => fn(...args)),
] as const);
ipcActions.forEach(([key, action]) => electron.ipcRenderer.on(key, action));
electron.ipcRenderer.on('apiAction', tryApiAction);
return () => {
actionsWithCatch.forEach(([key, action]) => electron.ipcRenderer.off(key, action));
electron.ipcRenderer.off('apiKeyboardAction', tryApiKeyboardAction);
ipcActions.forEach(([key, action]) => electron.ipcRenderer.off(key, action));
electron.ipcRenderer.off('apiAction', tryApiAction);
};
}, [checkFileOpened, customOutDir, detectedFps, filePath, getFrameCount, getKeyboardAction, loadCutSegments, mainActions, selectedSegments, toggleKeyboardShortcuts, userOpenFiles]);
}, [checkFileOpened, customOutDir, detectedFps, filePath, getFrameCount, getKeyboardAction, goToTimecodeDirect, loadCutSegments, mainActions, selectedSegments, toggleKeyboardShortcuts, userOpenFiles]);
useEffect(() => {
async function onDrop(ev: DragEvent) {

View File

@ -27,6 +27,12 @@ export interface ApparentSegmentBase {
export interface ApparentSegmentWithColorIndex extends ApparentSegmentBase, SegmentColorIndex {}
export const openFilesActionArgsSchema = z.tuple([z.string().array()]);
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 segmentTagsSchema = z.record(z.string(), z.string());
export type SegmentTags = z.infer<typeof segmentTagsSchema>

View File

@ -106,9 +106,10 @@ export interface Waveform {
buffer: Buffer,
}
export interface ApiKeyboardActionRequest {
export interface ApiActionRequest {
id: number
action: string
args?: unknown[] | undefined,
}
export type Html5ifyMode = 'fastest' | 'fast-audio-remux' | 'fast-audio' | 'fast' | 'slow' | 'slow-audio' | 'slowest';