1
0
mirror of https://github.com/mifi/lossless-cut.git synced 2024-11-22 02:12:30 +01:00

use js expressions instead of mathjs #2002

This commit is contained in:
Mikael Finstad 2024-05-19 23:16:52 +02:00
parent 096db54f11
commit ec3e626693
No known key found for this signature in database
GPG Key ID: 25AB36E3E81CBC26
8 changed files with 230 additions and 115 deletions

11
expressions.md Normal file
View File

@ -0,0 +1,11 @@
# Expressions
## Select segments by expression
LosslessCut has support for normal JavaScript expressions. You will be given a variable `segment` and can create an expression that returns `true` or `false`. For example to select all segments with a duration of less than 5 seconds use this expression:
```js
segment.duration < 5
```
See more examples in-app.

View File

@ -131,7 +131,6 @@
"i18next-fs-backend": "^2.1.1", "i18next-fs-backend": "^2.1.1",
"json5": "^2.2.2", "json5": "^2.2.2",
"lodash": "^4.17.19", "lodash": "^4.17.19",
"mathjs": "^12.4.2",
"mime-types": "^2.1.14", "mime-types": "^2.1.14",
"morgan": "^1.10.0", "morgan": "^1.10.0",
"semver": "^7.6.0", "semver": "^7.6.0",

View File

@ -15,7 +15,7 @@ import CopyClipboardButton from '../components/CopyClipboardButton';
import { isWindows, showItemInFolder } from '../util'; import { isWindows, showItemInFolder } from '../util';
import { ParseTimecode, SegmentBase } from '../types'; import { ParseTimecode, SegmentBase } from '../types';
const { dialog } = window.require('@electron/remote'); const { dialog, shell } = window.require('@electron/remote');
const ReactSwal = withReactContent(Swal); const ReactSwal = withReactContent(Swal);
@ -538,12 +538,13 @@ export async function selectSegmentsByLabelDialog(currentName: string) {
return value; return value;
} }
export async function selectSegmentsByExprDialog(inputValidator: (v: string) => string | undefined) { export async function selectSegmentsByExprDialog(inputValidator: (v: string) => Promise<string | undefined>) {
const examples = { const examples = {
duration: { name: i18n.t('Segment duration less than 5 seconds'), code: 'segment.duration < 5' }, duration: { name: i18n.t('Segment duration less than 5 seconds'), code: 'segment.duration < 5' },
start: { name: i18n.t('Segment starts after 00:60'), code: 'segment.start > 60' }, start: { name: i18n.t('Segment starts after 00:60'), code: 'segment.start > 60' },
label: { name: i18n.t('Segment label'), code: "equalText(segment.label, 'My label')" }, label: { name: i18n.t('Segment label (exact)'), code: "segment.label === 'My label'" },
tag: { name: i18n.t('Segment tag value'), code: "equalText(segment.tags.myTag, 'tag value')" }, regexp: { name: i18n.t('Segment label (regexp)'), code: '/^My label/.test(segment.label)' },
tag: { name: i18n.t('Segment tag value'), code: "segment.tags.myTag === 'tag value'" },
}; };
function addExample(type: string) { function addExample(type: string) {
@ -557,14 +558,10 @@ export async function selectSegmentsByExprDialog(inputValidator: (v: string) =>
html: ( html: (
<div style={{ textAlign: 'left' }}> <div style={{ textAlign: 'left' }}>
<div style={{ marginBottom: '1em' }}> <div style={{ marginBottom: '1em' }}>
{i18n.t('Enter an expression which will be evaluated for each segment. Segments for which the expression evaluates to "true" will be selected. For available syntax, see {{url}}.', { url: 'https://mathjs.org/' })} <Trans>Enter a JavaScript expression which will be evaluated for each segment. Segments for which the expression evaluates to &quot;true&quot; will be selected. <button type="button" className="button-unstyled" style={{ fontWeight: 'bold' }} onClick={() => shell.openExternal('https://github.com/mifi/lossless-cut/blob/master/expressions.md')}>View available syntax.</button></Trans>
</div> </div>
<div><b>{i18n.t('Variables')}:</b></div> <div style={{ marginBottom: '1em' }}><b>{i18n.t('Variables')}:</b> segment.label, segment.start, segment.end, segment.duration, segment.tags.*</div>
<div style={{ marginBottom: '1em' }}>
segment.label, segment.start, segment.end, segment.duration
</div>
<div><b>{i18n.t('Examples')}:</b></div> <div><b>{i18n.t('Examples')}:</b></div>

View File

@ -3,7 +3,6 @@ import { useStateWithHistory } from 'react-use/lib/useStateWithHistory';
import i18n from 'i18next'; import i18n from 'i18next';
import pMap from 'p-map'; import pMap from 'p-map';
import invariant from 'tiny-invariant'; import invariant from 'tiny-invariant';
import { evaluate } from 'mathjs';
import sortBy from 'lodash/sortBy'; import sortBy from 'lodash/sortBy';
import { detectSceneChanges as ffmpegDetectSceneChanges, readFrames, mapTimesToSegments, findKeyframeNearTime } from '../ffmpeg'; import { detectSceneChanges as ffmpegDetectSceneChanges, readFrames, mapTimesToSegments, findKeyframeNearTime } from '../ffmpeg';
@ -15,6 +14,8 @@ import { createSegment, findSegmentsAtCursor, sortSegments, invertSegments, comb
import * as ffmpegParameters from '../ffmpeg-parameters'; import * as ffmpegParameters from '../ffmpeg-parameters';
import { maxSegmentsAllowed } from '../util/constants'; import { maxSegmentsAllowed } from '../util/constants';
import { ParseTimecode, SegmentBase, SegmentToExport, StateSegment, UpdateSegAtIndex } from '../types'; import { ParseTimecode, SegmentBase, SegmentToExport, StateSegment, UpdateSegAtIndex } from '../types';
import safeishEval from '../worker/eval';
import { ScopeSegment } from '../../../../types';
const { ffmpeg: { blackDetect, silenceDetect } } = window.require('@electron/remote').require('./index.js'); const { ffmpeg: { blackDetect, silenceDetect } } = window.require('@electron/remote').require('./index.js');
@ -472,40 +473,34 @@ function useSegments({ filePath, workingRef, setWorking, setCutProgress, videoSt
}, [currentCutSeg, cutSegments, enableSegments]); }, [currentCutSeg, cutSegments, enableSegments]);
const onSelectSegmentsByExpr = useCallback(async () => { const onSelectSegmentsByExpr = useCallback(async () => {
function matchSegment(seg: StateSegment, expr: string) { async function matchSegment(seg: StateSegment, expr: string) {
const start = getSegApparentStart(seg); const start = getSegApparentStart(seg);
const end = getSegApparentEnd(seg); const end = getSegApparentEnd(seg);
// must clone tags because scope is mutable (editable by expression) // must clone tags because scope is mutable (editable by expression)
const scopeSegment: { label: string, start: number, end: number, duration: number, tags: Record<string, string> } = { label: seg.name, start, end, duration: end - start, tags: { ...seg.tags } }; const scopeSegment: ScopeSegment = { label: seg.name, start, end, duration: end - start, tags: { ...seg.tags } };
return evaluate(expr, { segment: scopeSegment }) === true; return (await safeishEval(expr, { segment: scopeSegment })) === true;
} }
const getSegmentsToEnable = (expr: string) => cutSegments.filter((seg) => { const getSegmentsToEnable = async (expr: string) => (await pMap(cutSegments, async (seg) => (
try { ((await matchSegment(seg, expr)) ? [seg] : [])
return matchSegment(seg, expr); ), { concurrency: 5 })).flat();
} catch (err) {
if (err instanceof TypeError) {
return false;
}
throw err;
}
});
const value = await selectSegmentsByExprDialog((v: string) => { const value = await selectSegmentsByExprDialog(async (v: string) => {
try { try {
const segments = getSegmentsToEnable(v); if (v.trim().length === 0) return i18n.t('Please enter a JavaScript expression.');
if (segments.length === 0) return i18n.t('No segments matched'); const segments = await getSegmentsToEnable(v);
if (segments.length === 0) return i18n.t('No segments match this expression.');
return undefined; return undefined;
} catch (err) { } catch (err) {
if (err instanceof Error) { if (err instanceof Error) {
return err.message; return i18n.t('Expression failed: {{errorMessage}}', { errorMessage: err.message });
} }
throw err; throw err;
} }
}); });
if (value == null) return; if (value == null) return;
const segmentsToEnable = getSegmentsToEnable(value); const segmentsToEnable = await getSegmentsToEnable(value);
enableSegments(segmentsToEnable); enableSegments(segmentsToEnable);
}, [cutSegments, enableSegments, getSegApparentEnd]); }, [cutSegments, enableSegments, getSegApparentEnd]);

View File

@ -0,0 +1,51 @@
const workerUrl = new URL('evalWorker.js', import.meta.url);
// https://v3.vitejs.dev/guide/features.html#web-workers
// todo terminate() and recreate in case of error?
const worker = new Worker(workerUrl);
let lastRequestId = 0;
export default async function safeishEval(code: string, context: unknown) {
return new Promise((resolve, reject) => {
lastRequestId += 1;
const id = lastRequestId;
// console.log({ lastRequestId, code, context })
function cleanup() {
// eslint-disable-next-line no-use-before-define
worker.removeEventListener('message', onMessage);
// eslint-disable-next-line no-use-before-define
worker.removeEventListener('messageerror', onMessageerror);
// eslint-disable-next-line no-use-before-define
worker.removeEventListener('error', onError);
}
function onMessage({ data: { id: responseId, error, data } }) {
// console.log('message', { responseId, error, data })
if (responseId === id) {
cleanup();
if (error) reject(new Error(error));
else resolve(data);
}
}
function onMessageerror() {
cleanup();
reject(new Error('safeishEval messageerror'));
}
function onError(err: ErrorEvent) {
cleanup();
reject(new Error(`safeishEval error: ${err.message}`));
}
worker.addEventListener('message', onMessage);
worker.addEventListener('messageerror', onMessageerror);
worker.addEventListener('error', onError);
worker.postMessage({ id, code, context: JSON.stringify(context) });
});
}

View File

@ -0,0 +1,138 @@
// eslint-disable-next-line unicorn/no-this-assignment, @typescript-eslint/no-this-alias
const myGlobal = this;
// https://stackoverflow.com/a/10796616/6519037
// https://github.com/Zirak/SO-ChatBot/blob/master/source/eval.js
// https://github.com/Zirak/SO-ChatBot/blob/master/source/codeWorker.js
const wl = {
self: 1,
onmessage: 1,
postMessage: 1,
global: 1,
wl: 1,
eval: 1,
Array: 1,
Boolean: 1,
Date: 1,
Function: 1,
Number: 1,
Object: 1,
RegExp: 1,
String: 1,
Error: 1,
EvalError: 1,
RangeError: 1,
ReferenceError: 1,
SyntaxError: 1,
TypeError: 1,
URIError: 1,
decodeURI: 1,
decodeURIComponent: 1,
encodeURI: 1,
encodeURIComponent: 1,
isFinite: 1,
isNaN: 1,
parseFloat: 1,
parseInt: 1,
Infinity: 1,
JSON: 1,
Math: 1,
NaN: 1,
undefined: 1,
// Chrome errors if you attempt to write over either of these properties, so put them in the whitelist
// https://github.com/owl-factory/lantern/blob/addda28034d5d30a7ea720646aa56fefa8f05cf4/archive/src/nodes/sandbox/workers/sandboxed-code.worker.ts#L47
TEMPORARY: 1,
PERSISTENT: 1,
};
// eslint-disable-next-line prefer-arrow-callback, func-names
Object.getOwnPropertyNames(myGlobal).forEach(function (prop) {
// eslint-disable-next-line no-prototype-builtins
if (!wl.hasOwnProperty(prop)) {
Object.defineProperty(myGlobal, prop, {
// eslint-disable-next-line func-names, object-shorthand
get: function () {
// eslint-disable-next-line no-throw-literal
throw `Security Exception: cannot access ${prop}`;
},
configurable: false,
});
}
});
// eslint-disable-next-line no-proto, prefer-arrow-callback, func-names
Object.getOwnPropertyNames(myGlobal.__proto__).forEach(function (prop) {
// eslint-disable-next-line no-prototype-builtins
if (!wl.hasOwnProperty(prop)) {
// eslint-disable-next-line no-proto
Object.defineProperty(myGlobal.__proto__, prop, {
// eslint-disable-next-line func-names, object-shorthand
get: function () {
// eslint-disable-next-line no-throw-literal
throw `Security Exception: cannot access ${prop}`;
},
configurable: false,
});
}
});
// Array(5000000000).join("adasdadadasd") instantly crashing some browser tabs
// eslint-disable-next-line no-extend-native
Object.defineProperty(Array.prototype, 'join', {
writable: false,
configurable: false,
enumerable: false,
// eslint-disable-next-line wrap-iife, func-names
value: function (old) {
// eslint-disable-next-line func-names
return function (arg) {
// @ts-expect-error dunno how to fix
if (this.length > 500 || (arg && arg.length > 500)) {
// eslint-disable-next-line no-throw-literal
throw 'Exception: too many items';
}
// eslint-disable-next-line unicorn/prefer-reflect-apply, prefer-rest-params
// @ts-expect-error dunno how to fix
return old.apply(this, arg);
};
}(Array.prototype.join),
});
/*
https://github.com/Zirak/SO-ChatBot/blob/accbfb4b8738781afaf4f080a6bb0337e13f7c25/source/codeWorker.js#L87
DOM specification doesn't define an enumerable `fetch` function object on
the global object so we add the property here, and the following code will
blacklist it. (`fetch` descends from `GlobalFetch`, and is thus present in
worker code as well)
Just in case someone runs the bot on some old browser where `fetch` is not
defined anyways, this will have no effect.
Reason for blacklisting fetch: well, same as XHR.
*/
// @ts-expect-error expected
myGlobal.fetch = undefined;
// eslint-disable-next-line wrap-iife, func-names
(function () {
onmessage = (event) => {
// eslint-disable-next-line strict, lines-around-directive
'use strict';
const { code, id, context: contextStr } = event.data;
const context = { ...JSON.parse(contextStr) };
try {
// https://stackoverflow.com/questions/8403108/calling-eval-in-particular-context
// eslint-disable-next-line unicorn/new-for-builtins, no-new-func
const result = Function(`\nwith (this) { return (${code}); }`).call(context);
postMessage({ id, data: result });
} catch (e) {
postMessage({ id, error: `${e}` });
}
};
})();

View File

@ -111,3 +111,12 @@ export interface ApiKeyboardActionRequest {
} }
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';
// This is the contract with the user, see https://github.com/mifi/lossless-cut/blob/master/expressions.md
export interface ScopeSegment {
label: string,
start: number,
end: number,
duration: number,
tags: Record<string, string>,
}

View File

@ -545,15 +545,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@babel/runtime@npm:^7.24.4":
version: 7.24.5
resolution: "@babel/runtime@npm:7.24.5"
dependencies:
regenerator-runtime: "npm:^0.14.0"
checksum: e0f4f4d4503f7338749d1dd92361ad132d683bde64e6b61d6c855e100dcd01592295fcfdcc960c946b85ef7908dc2f501080da58447c05812cf3cd80c599bb62
languageName: node
linkType: hard
"@babel/template@npm:^7.20.7": "@babel/template@npm:^7.20.7":
version: 7.20.7 version: 7.20.7
resolution: "@babel/template@npm:7.20.7" resolution: "@babel/template@npm:7.20.7"
@ -3907,13 +3898,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"complex.js@npm:^2.1.1":
version: 2.1.1
resolution: "complex.js@npm:2.1.1"
checksum: 1905d5204dd8a4d6f591182aca2045986f1ff3c5373e455ccd10c6ee2905bf1d3811a313d38c68f8a8507523202f91e25177387e3adc386c1b5b5ec2f13a6dbb
languageName: node
linkType: hard
"compute-scroll-into-view@npm:^1.0.14, compute-scroll-into-view@npm:^1.0.17": "compute-scroll-into-view@npm:^1.0.14, compute-scroll-into-view@npm:^1.0.17":
version: 1.0.17 version: 1.0.17
resolution: "compute-scroll-into-view@npm:1.0.17" resolution: "compute-scroll-into-view@npm:1.0.17"
@ -4254,13 +4238,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"decimal.js@npm:^10.4.3":
version: 10.4.3
resolution: "decimal.js@npm:10.4.3"
checksum: de663a7bc4d368e3877db95fcd5c87b965569b58d16cdc4258c063d231ca7118748738df17cd638f7e9dd0be8e34cec08d7234b20f1f2a756a52fc5a38b188d0
languageName: node
linkType: hard
"decompress-response@npm:^6.0.0": "decompress-response@npm:^6.0.0":
version: 6.0.0 version: 6.0.0
resolution: "decompress-response@npm:6.0.0" resolution: "decompress-response@npm:6.0.0"
@ -5218,13 +5195,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"escape-latex@npm:^1.2.0":
version: 1.2.0
resolution: "escape-latex@npm:1.2.0"
checksum: 73a787319f0965ecb8244bb38bf3a3cba872f0b9a5d3da8821140e9f39fe977045dc953a62b1a2bed4d12bfccbe75a7d8ec786412bf00739eaa2f627d0a8e0d6
languageName: node
linkType: hard
"escape-string-regexp@npm:5.0.0": "escape-string-regexp@npm:5.0.0":
version: 5.0.0 version: 5.0.0
resolution: "escape-string-regexp@npm:5.0.0" resolution: "escape-string-regexp@npm:5.0.0"
@ -5975,13 +5945,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"fraction.js@npm:4.3.4":
version: 4.3.4
resolution: "fraction.js@npm:4.3.4"
checksum: 3a1e6b268038ffdea625fab6a8d155d7ab644d35d0c99bc59084bfd29fbc714f3a38381b0627751ddb5f188bcde0b3f48c27e80eeb2ecd440825a7d2cd2bf9f1
languageName: node
linkType: hard
"framer-motion@npm:^9.0.3": "framer-motion@npm:^9.0.3":
version: 9.0.3 version: 9.0.3
resolution: "framer-motion@npm:9.0.3" resolution: "framer-motion@npm:9.0.3"
@ -7535,13 +7498,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"javascript-natural-sort@npm:^0.7.1":
version: 0.7.1
resolution: "javascript-natural-sort@npm:0.7.1"
checksum: 7bf6eab67871865d347f09a95aa770f9206c1ab0226bcda6fdd9edec340bf41111a7f82abac30556aa16a21cfa3b2b1ca4a362c8b73dd5ce15220e5d31f49d79
languageName: node
linkType: hard
"js-cookie@npm:^2.2.1": "js-cookie@npm:^2.2.1":
version: 2.2.1 version: 2.2.1
resolution: "js-cookie@npm:2.2.1" resolution: "js-cookie@npm:2.2.1"
@ -7993,7 +7949,6 @@ __metadata:
ky: "npm:^0.33.1" ky: "npm:^0.33.1"
lodash: "npm:^4.17.19" lodash: "npm:^4.17.19"
luxon: "npm:^3.3.0" luxon: "npm:^3.3.0"
mathjs: "npm:^12.4.2"
mime-types: "npm:^2.1.14" mime-types: "npm:^2.1.14"
mkdirp: "npm:^1.0.3" mkdirp: "npm:^1.0.3"
morgan: "npm:^1.10.0" morgan: "npm:^1.10.0"
@ -8185,25 +8140,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"mathjs@npm:^12.4.2":
version: 12.4.2
resolution: "mathjs@npm:12.4.2"
dependencies:
"@babel/runtime": "npm:^7.24.4"
complex.js: "npm:^2.1.1"
decimal.js: "npm:^10.4.3"
escape-latex: "npm:^1.2.0"
fraction.js: "npm:4.3.4"
javascript-natural-sort: "npm:^0.7.1"
seedrandom: "npm:^3.0.5"
tiny-emitter: "npm:^2.1.0"
typed-function: "npm:^4.1.1"
bin:
mathjs: bin/cli.js
checksum: 4b88ac1b137d00b8f3d66f4d1662d3670399390b59623ecf3ab7d587ba18be7b97ce9c5b07e953029ac75f48567d675c99323889ae231eb071ddd84db5dd699c
languageName: node
linkType: hard
"mdn-data@npm:2.0.14": "mdn-data@npm:2.0.14":
version: 2.0.14 version: 2.0.14
resolution: "mdn-data@npm:2.0.14" resolution: "mdn-data@npm:2.0.14"
@ -10362,13 +10298,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"seedrandom@npm:^3.0.5":
version: 3.0.5
resolution: "seedrandom@npm:3.0.5"
checksum: acad5e516c04289f61c2fb9848f449b95f58362b75406b79ec51e101ec885293fc57e3675d2f39f49716336559d7190f7273415d185fead8cd27b171ebf7d8fb
languageName: node
linkType: hard
"semver-compare@npm:^1.0.0": "semver-compare@npm:^1.0.0":
version: 1.0.0 version: 1.0.0
resolution: "semver-compare@npm:1.0.0" resolution: "semver-compare@npm:1.0.0"
@ -11280,13 +11209,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"tiny-emitter@npm:^2.1.0":
version: 2.1.0
resolution: "tiny-emitter@npm:2.1.0"
checksum: 75633f4de4f47f43af56aff6162f25b87be7efc6f669fda256658f3c3f4a216f23dc0d13200c6fafaaf1b0c7142f0201352fb06aec0b77f68aea96be898f4516
languageName: node
linkType: hard
"tiny-invariant@npm:1.2.0": "tiny-invariant@npm:1.2.0":
version: 1.2.0 version: 1.2.0
resolution: "tiny-invariant@npm:1.2.0" resolution: "tiny-invariant@npm:1.2.0"
@ -11614,13 +11536,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"typed-function@npm:^4.1.1":
version: 4.1.1
resolution: "typed-function@npm:4.1.1"
checksum: 0ef538d5f02e5c40659cccc14b5f2727f0e4181f11d91bb7897327c33cc2893de7e92343b6b32e1bb15e44a215a1e92e27ab2aa1353b100a9a2697abf2989a0c
languageName: node
linkType: hard
"typedarray-to-buffer@npm:^3.1.5": "typedarray-to-buffer@npm:^3.1.5":
version: 3.1.5 version: 3.1.5
resolution: "typedarray-to-buffer@npm:3.1.5" resolution: "typedarray-to-buffer@npm:3.1.5"