1
0
mirror of https://github.com/mifi/lossless-cut.git synced 2024-11-21 18:02:35 +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 ## 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). 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: Export the currently opened file:
```bash ```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 ### Batch example
Start the main LosslessCut in one terminal with the HTTP API enabled: 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 for PROJECT in /path/to/folder/with/projects/*.llc
LosslessCut $PROJECT LosslessCut $PROJECT
sleep 5 # wait for the file to open sleep 5 # wait for the file to open
curl -X POST http://localhost:8080/api/shortcuts/export curl -X POST http://localhost:8080/api/action/export
curl -X POST http://localhost:8080/api/shortcuts/closeCurrentFile curl -X POST http://localhost:8080/api/action/closeCurrentFile
done done
``` ```

View File

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

View File

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

View File

@ -9,6 +9,7 @@ import i18n from 'i18next';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { produce } from 'immer'; import { produce } from 'immer';
import screenfull from 'screenfull'; import screenfull from 'screenfull';
import { IpcRendererEvent } from 'electron';
import fromPairs from 'lodash/fromPairs'; import fromPairs from 'lodash/fromPairs';
import sortBy from 'lodash/sortBy'; import sortBy from 'lodash/sortBy';
@ -87,8 +88,8 @@ import { rightBarWidth, leftBarWidth, ffmpegExtractWindow, zoomMax } from './uti
import BigWaveform from './components/BigWaveform'; import BigWaveform from './components/BigWaveform';
import isDev from './isDev'; 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 { 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 } from '../../../types'; import { CaptureFormat, KeyboardAction, Html5ifyMode, WaveformMode, ApiActionRequest } from '../../../types';
import { FFprobeChapter, FFprobeFormat, FFprobeStream } from '../../../ffprobe'; import { FFprobeChapter, FFprobeFormat, FFprobeStream } from '../../../ffprobe';
const electron = window.require('electron'); const electron = window.require('electron');
@ -1734,18 +1735,26 @@ function App() {
const goToTimecode = useCallback(async () => { const goToTimecode = useCallback(async () => {
if (!filePath) return; if (!filePath) return;
const timeCode = await promptTimeOffset({ const timecode = await promptTimeOffset({
initialValue: formatTimecode({ seconds: commandedTimeRef.current }), initialValue: formatTimecode({ seconds: commandedTimeRef.current }),
title: i18n.t('Seek to timecode'), title: i18n.t('Seek to timecode'),
inputPlaceholder: timecodePlaceholder, inputPlaceholder: timecodePlaceholder,
parseTimecode, parseTimecode,
}); });
if (timeCode === undefined) return; if (timecode === undefined) return;
userSeekAbs(timeCode); userSeekAbs(timecode);
}, [filePath, formatTimecode, parseTimecode, timecodePlaceholder, userSeekAbs]); }, [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 toggleStreamsSelector = useCallback(() => setStreamsSelectorShown((v) => !v), []);
const handleShowStreamsSelectorClick = useCallback(() => { const handleShowStreamsSelectorClick = useCallback(() => {
@ -2099,7 +2108,7 @@ function App() {
onEditSegmentTags(currentSegIndexSafe); onEditSegmentTags(currentSegIndexSafe);
}, [currentSegIndexSafe, onEditSegmentTags]); }, [currentSegIndexSafe, onEditSegmentTags]);
type MainKeyboardAction = Exclude<KeyboardAction, 'closeActiveScreen' | 'toggleKeyboardShortcuts'>; type MainKeyboardAction = Exclude<KeyboardAction, 'closeActiveScreen' | 'toggleKeyboardShortcuts' | 'goToTimecodeDirect'>;
const mainActions = useMemo(() => { const mainActions = useMemo(() => {
async function exportYouTube() { async function exportYouTube() {
@ -2403,28 +2412,7 @@ function App() {
} }
} }
async function tryApiKeyboardAction(event, { id, action }) { const openFiles = (filePaths: string[]) => { userOpenFiles(filePaths.map((p) => resolvePathIfNeeded(p))); };
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,
};
async function actionWithCatch(fn: () => void) { async function actionWithCatch(fn: () => void) {
try { try {
@ -2434,30 +2422,83 @@ function App() {
} }
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any const allActions = [
const actionsWithCatch: Readonly<[string, (event: unknown, ...a: any) => Promise<void>]>[] = [
// actions with arguments: // 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, key,
async (_event: unknown, ...args: unknown[]) => actionWithCatch(() => fn(...args)), async (...args: unknown[]) => {
await (fn as (...args2: unknown[]) => Promise<void>)(...args);
},
] as const), ] as const),
// all main actions (no arguments, so simulate keyup): // all main actions (no arguments, so simulate keyup):
...Object.entries(mainActions).map(([key, fn]) => [ ...Object.entries(mainActions).map(([key, fn]) => [
key, key,
async () => actionWithCatch(() => fn({ keyup: true })), // eslint-disable-next-line @typescript-eslint/no-unused-vars
async () => {
fn({ keyup: true });
},
] as const), ] as const),
// also called from menu: // 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)); const allActionsMap = Object.fromEntries(allActions);
electron.ipcRenderer.on('apiKeyboardAction', tryApiKeyboardAction);
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 () => { return () => {
actionsWithCatch.forEach(([key, action]) => electron.ipcRenderer.off(key, action)); ipcActions.forEach(([key, action]) => electron.ipcRenderer.off(key, action));
electron.ipcRenderer.off('apiKeyboardAction', tryApiKeyboardAction); 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(() => { useEffect(() => {
async function onDrop(ev: DragEvent) { async function onDrop(ev: DragEvent) {

View File

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

View File

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