mirror of
https://github.com/mifi/lossless-cut.git
synced 2024-11-24 11:22:34 +01:00
allow passing arguments to api actions
also rename shortcuts to actions in api (breaking) closes #2087
This commit is contained in:
parent
e5f23510e1
commit
533074ddae
14
api.md
14
api.md
@ -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
|
||||
```
|
||||
|
@ -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();
|
||||
}));
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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>
|
||||
|
3
types.ts
3
types.ts
@ -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';
|
||||
|
Loading…
Reference in New Issue
Block a user