mirror of
https://github.com/mifi/lossless-cut.git
synced 2024-11-24 19:32:29 +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
|
## 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
|
||||||
```
|
```
|
||||||
|
@ -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();
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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) {
|
||||||
|
@ -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>
|
||||||
|
3
types.ts
3
types.ts
@ -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';
|
||||||
|
Loading…
Reference in New Issue
Block a user