allow editing segments by js expression

closes #2128 #2305
pull/2320/head
Mikael Finstad 1 year ago
parent edca7521a1
commit 06cae06f4b
No known key found for this signature in database
GPG Key ID: 25AB36E3E81CBC26

@ -1,11 +1,17 @@
# Expressions
LosslessCut has support for normal JavaScript 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:
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.
## Edit segments by expression
LosslessCut has support for normal JavaScript expressions. You will be given a variable `segment` for each selected segment and can return a new segment with modified properties.
See more examples in-app.

@ -333,7 +333,7 @@ function App() {
}, [isFileOpened]);
const {
cutSegments, cutSegmentsHistory, createSegmentsFromKeyframes, shuffleSegments, detectBlackScenes, detectSilentScenes, detectSceneChanges, removeCutSegment, invertAllSegments, fillSegmentsGaps, combineOverlappingSegments, combineSelectedSegments, shiftAllSegmentTimes, alignSegmentTimesToKeyframes, updateSegOrder, updateSegOrders, reorderSegsByStartTime, addSegment, setCutStart, setCutEnd, onLabelSegment, splitCurrentSegment, focusSegmentAtCursor, createNumSegments, createFixedDurationSegments, createRandomSegments, apparentCutSegments, haveInvalidSegs, currentSegIndexSafe, currentCutSeg, currentApparentCutSeg, inverseCutSegments, clearSegments, loadCutSegments, isSegmentSelected, setCutTime, setCurrentSegIndex, onLabelSelectedSegments, deselectAllSegments, selectAllSegments, selectOnlyCurrentSegment, toggleCurrentSegmentSelected, invertSelectedSegments, removeSelectedSegments, setDeselectedSegmentIds, onSelectSegmentsByLabel, onSelectSegmentsByExpr, toggleSegmentSelected, selectOnlySegment, getApparentCutSegmentById, selectedSegments, selectedSegmentsOrInverse, nonFilteredSegmentsOrInverse, segmentsToExport, duplicateCurrentSegment, duplicateSegment, updateSegAtIndex,
cutSegments, cutSegmentsHistory, createSegmentsFromKeyframes, shuffleSegments, detectBlackScenes, detectSilentScenes, detectSceneChanges, removeCutSegment, invertAllSegments, fillSegmentsGaps, combineOverlappingSegments, combineSelectedSegments, shiftAllSegmentTimes, alignSegmentTimesToKeyframes, updateSegOrder, updateSegOrders, reorderSegsByStartTime, addSegment, setCutStart, setCutEnd, onLabelSegment, splitCurrentSegment, focusSegmentAtCursor, createNumSegments, createFixedDurationSegments, createRandomSegments, apparentCutSegments, haveInvalidSegs, currentSegIndexSafe, currentCutSeg, currentApparentCutSeg, inverseCutSegments, clearSegments, loadCutSegments, isSegmentSelected, setCutTime, setCurrentSegIndex, onLabelSelectedSegments, deselectAllSegments, selectAllSegments, selectOnlyCurrentSegment, toggleCurrentSegmentSelected, invertSelectedSegments, removeSelectedSegments, setDeselectedSegmentIds, onSelectSegmentsByLabel, onSelectSegmentsByExpr, onMutateSegmentsByExpr, toggleSegmentSelected, selectOnlySegment, getApparentCutSegmentById, selectedSegments, selectedSegmentsOrInverse, nonFilteredSegmentsOrInverse, segmentsToExport, duplicateCurrentSegment, duplicateSegment, updateSegAtIndex,
} = useSegments({ filePath, workingRef, setWorking, setProgress, videoStream: activeVideoStream, duration, getRelevantTime, maxLabelLength, checkFileOpened, invertCutSegments, segmentsToChaptersOnly, timecodePlaceholder, parseTimecode, appendFfmpegCommandLog });
const { getEdlFilePath, getEdlFilePathOld, projectFileSavePath, getProjectFileSavePath } = useSegmentsAutoSave({ autoSaveProjectFile, storeProjectInWorkingDir, filePath, customOutDir, cutSegments });
@ -2499,6 +2499,7 @@ function App() {
jumpSegEnd={jumpSegEnd}
onSelectSegmentsByLabel={onSelectSegmentsByLabel}
onSelectSegmentsByExpr={onSelectSegmentsByExpr}
onMutateSegmentsByExpr={onMutateSegmentsByExpr}
onLabelSelectedSegments={onLabelSelectedSegments}
updateSegAtIndex={updateSegAtIndex}
editingSegmentTags={editingSegmentTags}

@ -47,6 +47,7 @@ const Segment = memo(({
onSelectSegmentsByLabel,
onSelectSegmentsByExpr,
onSelectAllSegments,
onMutateSegmentsByExpr,
jumpSegStart,
jumpSegEnd,
addSegment,
@ -74,6 +75,7 @@ const Segment = memo(({
onSelectSegmentsByLabel: UseSegments['onSelectSegmentsByLabel'],
onSelectSegmentsByExpr: UseSegments['onSelectSegmentsByExpr'],
onSelectAllSegments: UseSegments['selectAllSegments'],
onMutateSegmentsByExpr: UseSegments['onMutateSegmentsByExpr'],
jumpSegStart: (i: number) => void,
jumpSegEnd: (i: number) => void,
addSegment: UseSegments['addSegment'],
@ -117,6 +119,7 @@ const Segment = memo(({
{ label: t('Label selected segments'), click: onLabelSelectedSegments },
{ label: t('Remove selected segments'), click: onRemoveSelected },
{ label: t('Edit segments by expression'), click: () => onMutateSegmentsByExpr() },
{ type: 'separator' },
@ -129,7 +132,7 @@ const Segment = memo(({
{ label: t('Segment tags'), click: () => onEditSegmentTags(index) },
{ label: t('Extract frames as image files'), click: () => onExtractSegmentFramesAsImages([seg.segId]) },
];
}, [invertCutSegments, t, addSegment, onLabelSelectedSegments, onRemoveSelected, updateSegOrder, index, jumpSegStart, jumpSegEnd, onLabelPress, onRemovePress, onDuplicateSegmentClick, seg, onSelectSingleSegment, onSelectAllSegments, onDeselectAllSegments, onSelectSegmentsByLabel, onSelectSegmentsByExpr, onInvertSelectedSegments, onReorderPress, onEditSegmentTags, onExtractSegmentFramesAsImages]);
}, [invertCutSegments, t, addSegment, onLabelSelectedSegments, onRemoveSelected, updateSegOrder, index, jumpSegStart, jumpSegEnd, onLabelPress, onRemovePress, onDuplicateSegmentClick, seg, onSelectSingleSegment, onSelectAllSegments, onDeselectAllSegments, onSelectSegmentsByLabel, onSelectSegmentsByExpr, onInvertSelectedSegments, onMutateSegmentsByExpr, onReorderPress, onEditSegmentTags, onExtractSegmentFramesAsImages]);
useContextMenu(ref, contextMenuTemplate);
@ -245,6 +248,7 @@ function SegmentList({
onSelectAllSegments,
onSelectSegmentsByLabel,
onSelectSegmentsByExpr,
onMutateSegmentsByExpr,
onExtractSegmentFramesAsImages,
onLabelSelectedSegments,
onInvertSelectedSegments,
@ -283,6 +287,7 @@ function SegmentList({
onSelectAllSegments: UseSegments['selectAllSegments'],
onSelectSegmentsByLabel: UseSegments['onSelectSegmentsByLabel'],
onSelectSegmentsByExpr: UseSegments['onSelectSegmentsByExpr'],
onMutateSegmentsByExpr: UseSegments['onMutateSegmentsByExpr'],
onExtractSegmentFramesAsImages: (segIds: string[]) => Promise<void>,
onLabelSelectedSegments: UseSegments['onLabelSelectedSegments'],
onInvertSelectedSegments: UseSegments['invertSelectedSegments'],
@ -497,6 +502,7 @@ function SegmentList({
onEditSegmentTags={onEditSegmentTags}
onSelectSegmentsByLabel={onSelectSegmentsByLabel}
onSelectSegmentsByExpr={onSelectSegmentsByExpr}
onMutateSegmentsByExpr={onMutateSegmentsByExpr}
onExtractSegmentFramesAsImages={onExtractSegmentFramesAsImages}
onLabelSelectedSegments={onLabelSelectedSegments}
onInvertSelectedSegments={onInvertSelectedSegments}

@ -582,30 +582,29 @@ export async function selectSegmentsByLabelDialog(currentName: string) {
return value;
}
export async function selectSegmentsByExprDialog(inputValidator: (v: string) => Promise<string | undefined>) {
const examples = {
duration: { name: i18n.t('Segment duration less than 5 seconds'), code: 'segment.duration < 5' },
start: { name: i18n.t('Segment starts after 01:00'), code: 'segment.start > 60' },
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'" },
};
export async function exprDialog({ inputValidator, examples, title, description, variables }: {
inputValidator: (v: string) => Promise<string | undefined>,
examples: Record<string, { name: string, code: string }>,
title: string,
description: ReactNode,
variables: string[]
}) {
function addExample(type: string) {
Swal.getInput()!.value = examples[type]?.code ?? '';
}
const { value } = await ReactSwal.fire<string>({
showCancelButton: true,
title: i18n.t('Select segments by expression'),
title,
input: 'text',
width: '90vw',
html: (
<div style={{ textAlign: 'left' }}>
<div style={{ marginBottom: '1em' }}>
<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="link-button" onClick={() => shell.openExternal('https://github.com/mifi/lossless-cut/blob/master/expressions.md')}>View available syntax.</button></Trans>
{description}
</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' }}><b>{i18n.t('Variables')}:</b> {variables.join(', ')}</div>
<div><b>{i18n.t('Examples')}:</b></div>
@ -622,6 +621,39 @@ export async function selectSegmentsByExprDialog(inputValidator: (v: string) =>
return value;
}
export async function selectSegmentsByExprDialog(inputValidator: (v: string) => Promise<string | undefined>) {
return exprDialog({
inputValidator,
examples: {
duration: { name: i18n.t('Segment duration less than 5 seconds'), code: 'segment.duration < 5' },
start: { name: i18n.t('Segment starts after 01:00'), code: 'segment.start > 60' },
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'" },
},
title: i18n.t('Select segments by expression'),
description: <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="link-button" onClick={() => shell.openExternal('https://github.com/mifi/lossless-cut/blob/master/expressions.md')}>View available syntax.</button></Trans>,
variables: ['segment.index', 'segment.label', 'segment.start', 'segment.end', 'segment.duration', 'segment.tags.*'],
});
}
export async function mutateSegmentsByExprDialog(inputValidator: (v: string) => Promise<string | undefined>) {
return exprDialog({
inputValidator,
examples: {
center: { name: i18n.t('Feather segments +5 sec'), code: '{ start: segment.start - 5, end: segment.end + 5 }' },
feather: { name: i18n.t('Shrink segments -5 sec'), code: '{ start: segment.start + 5, end: segment.end - 5 }' },
shrink: { name: i18n.t('Center segments around start time'), code: '{ start: segment.start - 5, end: segment.start + 5 }' },
// eslint-disable-next-line no-template-curly-in-string
addNumToLabel: { name: i18n.t('Add number suffix to label'), code: '{ label: `${segment.label} ${segment.index + 1}` }' },
tagEven: { name: i18n.t('Add a tag to every even segment'), code: '{ tags: (segment.index + 1) % 2 === 0 ? { ...segment.tags, even: \'true\' } : segment.tags }' },
},
title: i18n.t('Edit segments by expression'),
description: <Trans>Enter a JavaScript expression which will be evaluated for each selected segment. Returned properties will be edited. <button type="button" className="link-button" onClick={() => shell.openExternal('https://github.com/mifi/lossless-cut/blob/master/expressions.md')}>View available syntax.</button></Trans>,
variables: ['segment.index', 'segment.label', 'segment.start', 'segment.end', 'segment.tags.*'],
});
}
export function showJson5Dialog({ title, json, darkMode }: { title: string, json: unknown, darkMode: boolean }) {
const html = (
<SyntaxHighlighter language="javascript" style={darkMode ? darkSyntaxStyle : lightSyntaxStyle} customStyle={{ textAlign: 'left', maxHeight: 300, overflowY: 'auto', fontSize: 14 }}>

@ -9,11 +9,11 @@ import { detectSceneChanges as ffmpegDetectSceneChanges, readFrames, mapTimesToS
import { handleError, shuffleArray } from '../util';
import { errorToast } from '../swal';
import { showParametersDialog } from '../dialogs/parameters';
import { createNumSegments as createNumSegmentsDialog, createFixedDurationSegments as createFixedDurationSegmentsDialog, createRandomSegments as createRandomSegmentsDialog, labelSegmentDialog, askForShiftSegments, askForAlignSegments, selectSegmentsByLabelDialog, selectSegmentsByExprDialog } from '../dialogs';
import { createNumSegments as createNumSegmentsDialog, createFixedDurationSegments as createFixedDurationSegmentsDialog, createRandomSegments as createRandomSegmentsDialog, labelSegmentDialog, askForShiftSegments, askForAlignSegments, selectSegmentsByLabelDialog, selectSegmentsByExprDialog, mutateSegmentsByExprDialog } from '../dialogs';
import { createSegment, findSegmentsAtCursor, sortSegments, invertSegments, combineOverlappingSegments as combineOverlappingSegments2, combineSelectedSegments as combineSelectedSegments2, isDurationValid, getSegApparentStart, getSegApparentEnd as getSegApparentEnd2, addSegmentColorIndex } from '../segments';
import { parameters as allFfmpegParameters, FfmpegDialog } from '../ffmpegParameters';
import { maxSegmentsAllowed } from '../util/constants';
import { ApparentCutSegment, ParseTimecode, SegmentBase, SegmentToExport, StateSegment, UpdateSegAtIndex } from '../types';
import { ApparentCutSegment, ParseTimecode, SegmentBase, segmentTagsSchema, SegmentToExport, StateSegment, UpdateSegAtIndex } from '../types';
import safeishEval from '../worker/eval';
import { ScopeSegment } from '../../../../types';
import { FFprobeStream } from '../../../../ffprobe';
@ -134,6 +134,14 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea
const getSegApparentEnd = useCallback((seg: SegmentBase) => getSegApparentEnd2(seg, duration), [duration]);
// must clone tags because scope is mutable (editable by expression)
const getScopeSegment = useCallback((seg: Pick<StateSegment, 'name' | 'start' | 'end' | 'tags'>, index: number): ScopeSegment => {
const start = getSegApparentStart(seg);
const end = getSegApparentEnd(seg);
return { index, label: seg.name, start, end, duration: end - start, tags: { ...seg.tags } };
}, [getSegApparentEnd]);
const getApparentCutSegments = useCallback((segments: StateSegment[]) => segments.map((cutSegment) => ({
...cutSegment,
start: getSegApparentStart(cutSegment),
@ -291,21 +299,32 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea
updateSegAtIndex(currentSegIndexSafe, { [type]: Math.min(Math.max(time, 0), duration) });
}, [currentSegIndexSafe, getSegApparentEnd, currentCutSeg, duration, updateSegAtIndex]);
const modifySelectedSegmentTimes = useCallback(async (transformSegment: <T extends ApparentCutSegment>(s: T) => Promise<T> | T, concurrency = 5) => {
if (duration == null) throw new Error();
const clampValue = (val: number) => Math.min(Math.max(val, 0), duration);
const clampValue = useCallback((val: number | undefined) => {
if (val == null) return undefined;
const clamped = Math.max(val, 0);
if (duration == null) return clamped;
return Math.min(clamped, duration);
}, [duration]);
const safeSetCutSegments = useCallback((newSegments: StateSegment[]) => {
const safeNewSegments = newSegments.map(({ start, end, ...rest }) => ({
...rest,
start: clampValue(start),
end: clampValue(end),
})).filter((segment) => segment.end == null || segment.start == null || segment.end > segment.start);
let newSegments = await pMap(apparentCutSegments, async (segment) => {
setCutSegments(safeNewSegments.length > 0 ? safeNewSegments : createInitialCutSegments());
}, [clampValue, createInitialCutSegments, setCutSegments]);
const modifySelectedSegmentTimes = useCallback(async (transformSegment: <T extends ApparentCutSegment>(s: T) => Promise<T> | T, concurrency = 5) => {
const newSegments = await pMap(apparentCutSegments, async (segment) => {
if (!isSegmentSelected(segment)) return segment; // pass thru non-selected segments
const newSegment = await transformSegment(segment);
newSegment.start = clampValue(newSegment.start);
newSegment.end = clampValue(newSegment.end);
return newSegment;
return transformSegment(segment);
}, { concurrency });
newSegments = newSegments.filter((segment) => segment.end > segment.start);
if (newSegments.length === 0) setCutSegments(createInitialCutSegments());
else setCutSegments(newSegments);
}, [apparentCutSegments, createInitialCutSegments, duration, isSegmentSelected, setCutSegments]);
safeSetCutSegments(newSegments);
}, [apparentCutSegments, isSegmentSelected, safeSetCutSegments]);
const shiftAllSegmentTimes = useCallback(async () => {
const shift = await askForShiftSegments({ inputPlaceholder: timecodePlaceholder, parseTimecode });
@ -531,16 +550,12 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea
}, [currentCutSeg, cutSegments, enableSegments]);
const onSelectSegmentsByExpr = useCallback(async () => {
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: ScopeSegment = { label: seg.name, start, end, duration: end - start, tags: { ...seg.tags } };
return (await safeishEval(expr, { segment: scopeSegment })) === true;
}
const matchSegment = async (seg: StateSegment, index: number, expr: string) => (
(await safeishEval(expr, { segment: getScopeSegment(seg, index) })) === true
);
const getSegmentsToEnable = async (expr: string) => (await pMap(cutSegments, async (seg) => (
((await matchSegment(seg, expr)) ? [seg] : [])
const getSegmentsToEnable = async (expr: string) => (await pMap(cutSegments, async (seg, index) => (
((await matchSegment(seg, index, expr)) ? [seg] : [])
), { concurrency: 5 })).flat();
const value = await selectSegmentsByExprDialog(async (v: string) => {
@ -561,7 +576,54 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea
if (value == null) return;
const segmentsToEnable = await getSegmentsToEnable(value);
enableSegments(segmentsToEnable);
}, [cutSegments, enableSegments, getSegApparentEnd]);
}, [cutSegments, enableSegments, getScopeSegment]);
const onMutateSegmentsByExpr = useCallback(async () => {
async function mutateSegment(seg: StateSegment, index: number, expr: string) {
const response = (await safeishEval(expr, { segment: getScopeSegment(seg, index) }));
invariant(typeof response === 'object' && response != null, i18n.t('The expression must return an object'));
const ret: Partial<Pick<StateSegment, 'name' | 'start' | 'end' | 'tags'>> = {};
if ('label' in response) {
if (typeof response.label !== 'string') throw new Error(i18n.t('"{{property}}" must be a string', { property: 'label' }));
ret.name = response.label;
}
if ('start' in response) {
if (typeof response.start !== 'number') throw new Error(i18n.t('"{{property}}" must be a number', { property: 'start' }));
ret.start = response.start;
}
if ('end' in response) {
if (typeof response.end !== 'number') throw new Error(i18n.t('"{{property}}" must be a number', { property: 'end' }));
ret.end = response.end;
}
if ('tags' in response) {
const tags = segmentTagsSchema.safeParse(response.tags);
if (!tags.success) throw new Error(i18n.t('"{{property}}" must be an object of strings', { property: 'tags' }));
ret.tags = tags.data;
}
return ret;
}
const mutateSegments = async (expr: string) => (await pMap(cutSegments, async (seg, index) => ({
...seg,
...(isSegmentSelected(seg) && await mutateSegment(seg, index, expr)),
}), { concurrency: 5 })).flat();
const value = await mutateSegmentsByExprDialog(async (v: string) => {
try {
if (v.trim().length === 0) return i18n.t('Please enter a JavaScript expression.');
await mutateSegments(v);
return undefined;
} catch (err) {
if (err instanceof Error) {
return i18n.t('Expression failed: {{errorMessage}}', { errorMessage: err.message });
}
throw err;
}
});
if (value == null) return;
safeSetCutSegments(await mutateSegments(value));
}, [cutSegments, getScopeSegment, isSegmentSelected, safeSetCutSegments]);
const onLabelSelectedSegments = useCallback(async () => {
if (selectedSegmentsRaw.length === 0) return;
@ -656,6 +718,7 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea
removeSelectedSegments,
onSelectSegmentsByLabel,
onSelectSegmentsByExpr,
onMutateSegmentsByExpr,
toggleSegmentSelected,
selectOnlySegment,
setCutTime,

@ -131,6 +131,7 @@ export type WaveformMode = 'big-waveform' | 'waveform';
// This is the contract with the user, see https://github.com/mifi/lossless-cut/blob/master/expressions.md
export interface ScopeSegment {
index: number,
label: string,
start: number,
end: number,

Loading…
Cancel
Save