diff --git a/expressions.md b/expressions.md new file mode 100644 index 0000000..3afa1bd --- /dev/null +++ b/expressions.md @@ -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. \ No newline at end of file diff --git a/package.json b/package.json index 88947b0..c6de85e 100644 --- a/package.json +++ b/package.json @@ -131,7 +131,6 @@ "i18next-fs-backend": "^2.1.1", "json5": "^2.2.2", "lodash": "^4.17.19", - "mathjs": "^12.4.2", "mime-types": "^2.1.14", "morgan": "^1.10.0", "semver": "^7.6.0", diff --git a/src/renderer/src/dialogs/index.tsx b/src/renderer/src/dialogs/index.tsx index 6b3e47a..165b8bb 100644 --- a/src/renderer/src/dialogs/index.tsx +++ b/src/renderer/src/dialogs/index.tsx @@ -15,7 +15,7 @@ import CopyClipboardButton from '../components/CopyClipboardButton'; import { isWindows, showItemInFolder } from '../util'; import { ParseTimecode, SegmentBase } from '../types'; -const { dialog } = window.require('@electron/remote'); +const { dialog, shell } = window.require('@electron/remote'); const ReactSwal = withReactContent(Swal); @@ -538,12 +538,13 @@ export async function selectSegmentsByLabelDialog(currentName: string) { return value; } -export async function selectSegmentsByExprDialog(inputValidator: (v: string) => string | undefined) { +export async function selectSegmentsByExprDialog(inputValidator: (v: string) => Promise) { const examples = { 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' }, - label: { name: i18n.t('Segment label'), code: "equalText(segment.label, 'My label')" }, - tag: { name: i18n.t('Segment tag value'), code: "equalText(segment.tags.myTag, 'tag value')" }, + label: { name: i18n.t('Segment label (exact)'), code: "segment.label === 'My label'" }, + 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) { @@ -557,14 +558,10 @@ export async function selectSegmentsByExprDialog(inputValidator: (v: string) => html: (
- {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/' })} + Enter a JavaScript expression which will be evaluated for each segment. Segments for which the expression evaluates to "true" will be selected.
-
{i18n.t('Variables')}:
- -
- segment.label, segment.start, segment.end, segment.duration -
+
{i18n.t('Variables')}: segment.label, segment.start, segment.end, segment.duration, segment.tags.*
{i18n.t('Examples')}:
diff --git a/src/renderer/src/hooks/useSegments.ts b/src/renderer/src/hooks/useSegments.ts index 453eb45..6d91bf3 100644 --- a/src/renderer/src/hooks/useSegments.ts +++ b/src/renderer/src/hooks/useSegments.ts @@ -3,7 +3,6 @@ import { useStateWithHistory } from 'react-use/lib/useStateWithHistory'; import i18n from 'i18next'; import pMap from 'p-map'; import invariant from 'tiny-invariant'; -import { evaluate } from 'mathjs'; import sortBy from 'lodash/sortBy'; 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 { maxSegmentsAllowed } from '../util/constants'; 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'); @@ -472,40 +473,34 @@ function useSegments({ filePath, workingRef, setWorking, setCutProgress, videoSt }, [currentCutSeg, cutSegments, enableSegments]); const onSelectSegmentsByExpr = useCallback(async () => { - function matchSegment(seg: StateSegment, expr: string) { + async function matchSegment(seg: StateSegment, expr: string) { const start = getSegApparentStart(seg); const end = getSegApparentEnd(seg); // must clone tags because scope is mutable (editable by expression) - const scopeSegment: { label: string, start: number, end: number, duration: number, tags: Record } = { label: seg.name, start, end, duration: end - start, tags: { ...seg.tags } }; - return evaluate(expr, { segment: scopeSegment }) === true; + const scopeSegment: ScopeSegment = { label: seg.name, start, end, duration: end - start, tags: { ...seg.tags } }; + return (await safeishEval(expr, { segment: scopeSegment })) === true; } - const getSegmentsToEnable = (expr: string) => cutSegments.filter((seg) => { - try { - return matchSegment(seg, expr); - } catch (err) { - if (err instanceof TypeError) { - return false; - } - throw err; - } - }); + const getSegmentsToEnable = async (expr: string) => (await pMap(cutSegments, async (seg) => ( + ((await matchSegment(seg, expr)) ? [seg] : []) + ), { concurrency: 5 })).flat(); - const value = await selectSegmentsByExprDialog((v: string) => { + const value = await selectSegmentsByExprDialog(async (v: string) => { try { - const segments = getSegmentsToEnable(v); - if (segments.length === 0) return i18n.t('No segments matched'); + if (v.trim().length === 0) return i18n.t('Please enter a JavaScript expression.'); + const segments = await getSegmentsToEnable(v); + if (segments.length === 0) return i18n.t('No segments match this expression.'); return undefined; } catch (err) { if (err instanceof Error) { - return err.message; + return i18n.t('Expression failed: {{errorMessage}}', { errorMessage: err.message }); } throw err; } }); if (value == null) return; - const segmentsToEnable = getSegmentsToEnable(value); + const segmentsToEnable = await getSegmentsToEnable(value); enableSegments(segmentsToEnable); }, [cutSegments, enableSegments, getSegApparentEnd]); diff --git a/src/renderer/src/worker/eval.ts b/src/renderer/src/worker/eval.ts new file mode 100644 index 0000000..90abb93 --- /dev/null +++ b/src/renderer/src/worker/eval.ts @@ -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) }); + }); +} diff --git a/src/renderer/src/worker/evalWorker.ts b/src/renderer/src/worker/evalWorker.ts new file mode 100644 index 0000000..b81669f --- /dev/null +++ b/src/renderer/src/worker/evalWorker.ts @@ -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}` }); + } + }; +})(); diff --git a/types.ts b/types.ts index 7160605..7b7b873 100644 --- a/types.ts +++ b/types.ts @@ -111,3 +111,12 @@ export interface ApiKeyboardActionRequest { } 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, +} diff --git a/yarn.lock b/yarn.lock index 8a693bf..7598a35 100644 --- a/yarn.lock +++ b/yarn.lock @@ -545,15 +545,6 @@ __metadata: languageName: node 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": version: 7.20.7 resolution: "@babel/template@npm:7.20.7" @@ -3907,13 +3898,6 @@ __metadata: languageName: node 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": version: 1.0.17 resolution: "compute-scroll-into-view@npm:1.0.17" @@ -4254,13 +4238,6 @@ __metadata: languageName: node 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": version: 6.0.0 resolution: "decompress-response@npm:6.0.0" @@ -5218,13 +5195,6 @@ __metadata: languageName: node 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": version: 5.0.0 resolution: "escape-string-regexp@npm:5.0.0" @@ -5975,13 +5945,6 @@ __metadata: languageName: node 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": version: 9.0.3 resolution: "framer-motion@npm:9.0.3" @@ -7535,13 +7498,6 @@ __metadata: languageName: node 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": version: 2.2.1 resolution: "js-cookie@npm:2.2.1" @@ -7993,7 +7949,6 @@ __metadata: ky: "npm:^0.33.1" lodash: "npm:^4.17.19" luxon: "npm:^3.3.0" - mathjs: "npm:^12.4.2" mime-types: "npm:^2.1.14" mkdirp: "npm:^1.0.3" morgan: "npm:^1.10.0" @@ -8185,25 +8140,6 @@ __metadata: languageName: node 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": version: 2.0.14 resolution: "mdn-data@npm:2.0.14" @@ -10362,13 +10298,6 @@ __metadata: languageName: node 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": version: 1.0.0 resolution: "semver-compare@npm:1.0.0" @@ -11280,13 +11209,6 @@ __metadata: languageName: node 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": version: 1.2.0 resolution: "tiny-invariant@npm:1.2.0" @@ -11614,13 +11536,6 @@ __metadata: languageName: node 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": version: 3.1.5 resolution: "typedarray-to-buffer@npm:3.1.5"