Skip to content

Commit b5028dc

Browse files
committed
allow selecting segments by expression
also remove select by tags dialog to reduce code it's covered by expression fixes #1999
1 parent 58adc59 commit b5028dc

File tree

6 files changed

+179
-40
lines changed

6 files changed

+179
-40
lines changed

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@
131131
"i18next-fs-backend": "^2.1.1",
132132
"json5": "^2.2.2",
133133
"lodash": "^4.17.19",
134+
"mathjs": "^12.4.2",
134135
"mime-types": "^2.1.14",
135136
"morgan": "^1.10.0",
136137
"semver": "^7.6.0",

src/renderer/src/App.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -406,7 +406,7 @@ function App() {
406406
}, [detectedFps, timecodeFormat, getFrameCount]);
407407

408408
const {
409-
cutSegments, cutSegmentsHistory, createSegmentsFromKeyframes, shuffleSegments, detectBlackScenes, detectSilentScenes, detectSceneChanges, removeCutSegment, invertAllSegments, fillSegmentsGaps, combineOverlappingSegments, combineSelectedSegments, shiftAllSegmentTimes, alignSegmentTimesToKeyframes, updateSegOrder, updateSegOrders, reorderSegsByStartTime, addSegment, setCutStart, setCutEnd, onLabelSegment, splitCurrentSegment, createNumSegments, createFixedDurationSegments, createRandomSegments, apparentCutSegments, haveInvalidSegs, currentSegIndexSafe, currentCutSeg, currentApparentCutSeg, inverseCutSegments, clearSegments, loadCutSegments, isSegmentSelected, setCutTime, setCurrentSegIndex, onLabelSelectedSegments, deselectAllSegments, selectAllSegments, selectOnlyCurrentSegment, toggleCurrentSegmentSelected, invertSelectedSegments, removeSelectedSegments, setDeselectedSegmentIds, onSelectSegmentsByLabel, onSelectSegmentsByTag, toggleSegmentSelected, selectOnlySegment, getApparentCutSegmentById, selectedSegments, selectedSegmentsOrInverse, nonFilteredSegmentsOrInverse, segmentsToExport, duplicateCurrentSegment, duplicateSegment, updateSegAtIndex,
409+
cutSegments, cutSegmentsHistory, createSegmentsFromKeyframes, shuffleSegments, detectBlackScenes, detectSilentScenes, detectSceneChanges, removeCutSegment, invertAllSegments, fillSegmentsGaps, combineOverlappingSegments, combineSelectedSegments, shiftAllSegmentTimes, alignSegmentTimesToKeyframes, updateSegOrder, updateSegOrders, reorderSegsByStartTime, addSegment, setCutStart, setCutEnd, onLabelSegment, splitCurrentSegment, 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,
410410
} = useSegments({ filePath, workingRef, setWorking, setCutProgress, videoStream: activeVideoStream, duration, getRelevantTime, maxLabelLength, checkFileOpened, invertCutSegments, segmentsToChaptersOnly, timecodePlaceholder, parseTimecode });
411411

412412

@@ -2637,7 +2637,7 @@ function App() {
26372637
jumpSegStart={jumpSegStart}
26382638
jumpSegEnd={jumpSegEnd}
26392639
onSelectSegmentsByLabel={onSelectSegmentsByLabel}
2640-
onSelectSegmentsByTag={onSelectSegmentsByTag}
2640+
onSelectSegmentsByExpr={onSelectSegmentsByExpr}
26412641
onLabelSelectedSegments={onLabelSelectedSegments}
26422642
updateSegAtIndex={updateSegAtIndex}
26432643
editingSegmentTags={editingSegmentTags}

src/renderer/src/SegmentList.tsx

+7-7
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ const Segment = memo(({
4444
onToggleSegmentSelected,
4545
onDeselectAllSegments,
4646
onSelectSegmentsByLabel,
47-
onSelectSegmentsByTag,
47+
onSelectSegmentsByExpr,
4848
onSelectAllSegments,
4949
jumpSegStart,
5050
jumpSegEnd,
@@ -71,7 +71,7 @@ const Segment = memo(({
7171
onToggleSegmentSelected: UseSegments['toggleSegmentSelected'],
7272
onDeselectAllSegments: UseSegments['deselectAllSegments'],
7373
onSelectSegmentsByLabel: UseSegments['onSelectSegmentsByLabel'],
74-
onSelectSegmentsByTag: UseSegments['onSelectSegmentsByTag'],
74+
onSelectSegmentsByExpr: UseSegments['onSelectSegmentsByExpr'],
7575
onSelectAllSegments: UseSegments['selectAllSegments'],
7676
jumpSegStart: (i: number) => void,
7777
jumpSegEnd: (i: number) => void,
@@ -109,7 +109,7 @@ const Segment = memo(({
109109
{ label: t('Select all segments'), click: () => onSelectAllSegments() },
110110
{ label: t('Deselect all segments'), click: () => onDeselectAllSegments() },
111111
{ label: t('Select segments by label'), click: () => onSelectSegmentsByLabel() },
112-
{ label: t('Select segments by tag'), click: () => onSelectSegmentsByTag() },
112+
{ label: t('Select segments by expression'), click: () => onSelectSegmentsByExpr() },
113113
{ label: t('Invert selected segments'), click: () => onInvertSelectedSegments() },
114114

115115
{ type: 'separator' },
@@ -128,7 +128,7 @@ const Segment = memo(({
128128
{ label: t('Segment tags'), click: () => onEditSegmentTags(index) },
129129
{ label: t('Extract frames as image files'), click: () => onExtractSegmentFramesAsImages([seg.segId]) },
130130
];
131-
}, [invertCutSegments, t, addSegment, onLabelSelectedSegments, onRemoveSelected, updateSegOrder, index, jumpSegStart, jumpSegEnd, onLabelPress, onRemovePress, onDuplicateSegmentClick, seg, onSelectSingleSegment, onSelectAllSegments, onDeselectAllSegments, onSelectSegmentsByLabel, onSelectSegmentsByTag, onInvertSelectedSegments, onReorderPress, onEditSegmentTags, onExtractSegmentFramesAsImages]);
131+
}, [invertCutSegments, t, addSegment, onLabelSelectedSegments, onRemoveSelected, updateSegOrder, index, jumpSegStart, jumpSegEnd, onLabelPress, onRemovePress, onDuplicateSegmentClick, seg, onSelectSingleSegment, onSelectAllSegments, onDeselectAllSegments, onSelectSegmentsByLabel, onSelectSegmentsByExpr, onInvertSelectedSegments, onReorderPress, onEditSegmentTags, onExtractSegmentFramesAsImages]);
132132

133133
useContextMenu(ref, contextMenuTemplate);
134134

@@ -243,7 +243,7 @@ const SegmentList = memo(({
243243
onDeselectAllSegments,
244244
onSelectAllSegments,
245245
onSelectSegmentsByLabel,
246-
onSelectSegmentsByTag,
246+
onSelectSegmentsByExpr,
247247
onExtractSegmentFramesAsImages,
248248
onLabelSelectedSegments,
249249
onInvertSelectedSegments,
@@ -281,7 +281,7 @@ const SegmentList = memo(({
281281
onDeselectAllSegments: UseSegments['deselectAllSegments'],
282282
onSelectAllSegments: UseSegments['selectAllSegments'],
283283
onSelectSegmentsByLabel: UseSegments['onSelectSegmentsByLabel'],
284-
onSelectSegmentsByTag: UseSegments['onSelectSegmentsByTag'],
284+
onSelectSegmentsByExpr: UseSegments['onSelectSegmentsByExpr'],
285285
onExtractSegmentFramesAsImages: (segIds: string[]) => Promise<void>,
286286
onLabelSelectedSegments: UseSegments['onLabelSelectedSegments'],
287287
onInvertSelectedSegments: UseSegments['invertSelectedSegments'],
@@ -487,7 +487,7 @@ const SegmentList = memo(({
487487
onSelectAllSegments={onSelectAllSegments}
488488
onEditSegmentTags={onEditSegmentTags}
489489
onSelectSegmentsByLabel={onSelectSegmentsByLabel}
490-
onSelectSegmentsByTag={onSelectSegmentsByTag}
490+
onSelectSegmentsByExpr={onSelectSegmentsByExpr}
491491
onExtractSegmentFramesAsImages={onExtractSegmentFramesAsImages}
492492
onLabelSelectedSegments={onLabelSelectedSegments}
493493
onInvertSelectedSegments={onInvertSelectedSegments}

src/renderer/src/dialogs/index.tsx

+44-21
Original file line numberDiff line numberDiff line change
@@ -434,7 +434,7 @@ export async function createFixedDurationSegments({ fileDuration, inputPlacehold
434434
return edl;
435435
}
436436

437-
export async function createRandomSegments(fileDuration) {
437+
export async function createRandomSegments(fileDuration: number) {
438438
const response = await askForSegmentsRandomDurationRange();
439439
if (response == null) return undefined;
440440

@@ -451,14 +451,14 @@ export async function createRandomSegments(fileDuration) {
451451
return edl;
452452
}
453453

454-
const MovSuggestion = ({ fileFormat }) => (fileFormat === 'mp4' ? <li><Trans>Change output <b>Format</b> from <b>MP4</b> to <b>MOV</b></Trans></li> : null);
454+
const MovSuggestion = ({ fileFormat }: { fileFormat: string | undefined }) => (fileFormat === 'mp4' ? <li><Trans>Change output <b>Format</b> from <b>MP4</b> to <b>MOV</b></Trans></li> : null);
455455
const OutputFormatSuggestion = () => <li><Trans>Select a different output <b>Format</b> (<b>matroska</b> and <b>mp4</b> support most codecs)</Trans></li>;
456456
const WorkingDirectorySuggestion = () => <li><Trans>Set a different <b>Working directory</b></Trans></li>;
457457
const DifferentFileSuggestion = () => <li><Trans>Try with a <b>Different file</b></Trans></li>;
458458
const HelpSuggestion = () => <li><Trans>See <b>Help</b></Trans> menu</li>;
459459
const ErrorReportSuggestion = () => <li><Trans>If nothing helps, you can send an <b>Error report</b></Trans></li>;
460460

461-
export async function showExportFailedDialog({ fileFormat, safeOutputFileName }) {
461+
export async function showExportFailedDialog({ fileFormat, safeOutputFileName }: { fileFormat: string | undefined, safeOutputFileName: boolean }) {
462462
const html = (
463463
<div style={{ textAlign: 'left' }}>
464464
<Trans>Try one of the following before exporting again:</Trans>
@@ -480,7 +480,7 @@ export async function showExportFailedDialog({ fileFormat, safeOutputFileName })
480480
return value;
481481
}
482482

483-
export async function showConcatFailedDialog({ fileFormat }) {
483+
export async function showConcatFailedDialog({ fileFormat }: { fileFormat: string | undefined }) {
484484
const html = (
485485
<div style={{ textAlign: 'left' }}>
486486
<Trans>Try each of the following before merging again:</Trans>
@@ -517,7 +517,7 @@ export function openYouTubeChaptersDialog(text: string) {
517517
});
518518
}
519519

520-
export async function labelSegmentDialog({ currentName, maxLength }) {
520+
export async function labelSegmentDialog({ currentName, maxLength }: { currentName: string, maxLength: number }) {
521521
const { value } = await Swal.fire({
522522
showCancelButton: true,
523523
title: i18n.t('Label current segment'),
@@ -528,7 +528,7 @@ export async function labelSegmentDialog({ currentName, maxLength }) {
528528
return value;
529529
}
530530

531-
export async function selectSegmentsByLabelDialog(currentName) {
531+
export async function selectSegmentsByLabelDialog(currentName: string) {
532532
const { value } = await Swal.fire({
533533
showCancelButton: true,
534534
title: i18n.t('Select segments by label'),
@@ -538,24 +538,47 @@ export async function selectSegmentsByLabelDialog(currentName) {
538538
return value;
539539
}
540540

541-
export async function selectSegmentsByTagDialog() {
542-
const { value: value1 } = await Swal.fire({
543-
showCancelButton: true,
544-
title: i18n.t('Select segments by tag'),
545-
text: i18n.t('Enter tag name (in the next dialog you\'ll enter tag value)'),
546-
input: 'text',
547-
});
548-
if (!value1) return undefined;
541+
export async function selectSegmentsByExprDialog(inputValidator: (v: string) => string | undefined) {
542+
const examples = {
543+
duration: { name: i18n.t('Segment duration less than 5 seconds'), code: 'segment.duration < 5' },
544+
start: { name: i18n.t('Segment starts after 00:60'), code: 'segment.start > 60' },
545+
label: { name: i18n.t('Segment label'), code: "equalText(segment.label, 'My label')" },
546+
tag: { name: i18n.t('Segment tag value'), code: "equalText(segment.tags.myTag, 'tag value')" },
547+
};
548+
549+
function addExample(type: string) {
550+
Swal.getInput()!.value = examples[type]?.code ?? '';
551+
}
549552

550-
const { value: value2 } = await Swal.fire({
553+
const { value } = await ReactSwal.fire<string>({
551554
showCancelButton: true,
552-
title: i18n.t('Select segments by tag'),
553-
text: i18n.t('Enter tag value'),
555+
title: i18n.t('Select segments by expression'),
554556
input: 'text',
555-
});
556-
if (!value2) return undefined;
557+
html: (
558+
<div style={{ textAlign: 'left' }}>
559+
<div style={{ marginBottom: '1em' }}>
560+
{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/' })}
561+
</div>
557562

558-
return { tagName: value1, tagValue: value2 };
563+
<div><b>{i18n.t('Variables')}:</b></div>
564+
565+
<div style={{ marginBottom: '1em' }}>
566+
segment.label, segment.start, segment.end, segment.duration
567+
</div>
568+
569+
<div><b>{i18n.t('Examples')}:</b></div>
570+
571+
{Object.entries(examples).map(([key, { name }]) => (
572+
<button key={key} type="button" onClick={() => addExample(key)} className="button-unstyled" style={{ display: 'block', marginBottom: '.1em' }}>
573+
{name}
574+
</button>
575+
))}
576+
</div>
577+
),
578+
inputPlaceholder: 'segment.duration < 5',
579+
inputValidator,
580+
});
581+
return value;
559582
}
560583

561584
export function showJson5Dialog({ title, json }: { title: string, json: unknown }) {
@@ -631,7 +654,7 @@ export async function askForPlaybackRate({ detectedFps, outputPlaybackRate }) {
631654
const fps = detectedFps || 1;
632655
const currentFps = fps * outputPlaybackRate;
633656

634-
function parseValue(v) {
657+
function parseValue(v: string) {
635658
const newFps = parseFloat(v);
636659
if (!Number.isNaN(newFps)) {
637660
return newFps / fps;

src/renderer/src/hooks/useSegments.ts

+40-10
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,15 @@ import { useStateWithHistory } from 'react-use/lib/useStateWithHistory';
33
import i18n from 'i18next';
44
import pMap from 'p-map';
55
import invariant from 'tiny-invariant';
6-
6+
import { evaluate } from 'mathjs';
77
import sortBy from 'lodash/sortBy';
88

99
import { detectSceneChanges as ffmpegDetectSceneChanges, readFrames, mapTimesToSegments, findKeyframeNearTime } from '../ffmpeg';
1010
import { handleError, shuffleArray } from '../util';
1111
import { errorToast } from '../swal';
1212
import { showParametersDialog } from '../dialogs/parameters';
13-
import { createNumSegments as createNumSegmentsDialog, createFixedDurationSegments as createFixedDurationSegmentsDialog, createRandomSegments as createRandomSegmentsDialog, labelSegmentDialog, askForShiftSegments, askForAlignSegments, selectSegmentsByLabelDialog, selectSegmentsByTagDialog } from '../dialogs';
14-
import { createSegment, findSegmentsAtCursor, sortSegments, invertSegments, getSegmentTags, combineOverlappingSegments as combineOverlappingSegments2, combineSelectedSegments as combineSelectedSegments2, isDurationValid, getSegApparentStart, getSegApparentEnd as getSegApparentEnd2, addSegmentColorIndex } from '../segments';
13+
import { createNumSegments as createNumSegmentsDialog, createFixedDurationSegments as createFixedDurationSegmentsDialog, createRandomSegments as createRandomSegmentsDialog, labelSegmentDialog, askForShiftSegments, askForAlignSegments, selectSegmentsByLabelDialog, selectSegmentsByExprDialog } from '../dialogs';
14+
import { createSegment, findSegmentsAtCursor, sortSegments, invertSegments, combineOverlappingSegments as combineOverlappingSegments2, combineSelectedSegments as combineSelectedSegments2, isDurationValid, getSegApparentStart, getSegApparentEnd as getSegApparentEnd2, addSegmentColorIndex } from '../segments';
1515
import * as ffmpegParameters from '../ffmpeg-parameters';
1616
import { maxSegmentsAllowed } from '../util/constants';
1717
import { ParseTimecode, SegmentBase, SegmentToExport, StateSegment, UpdateSegAtIndex } from '../types';
@@ -454,7 +454,7 @@ function useSegments({ filePath, workingRef, setWorking, setCutProgress, videoSt
454454
if (segments) loadCutSegments(segments);
455455
}, [checkFileOpened, duration, loadCutSegments]);
456456

457-
const enableSegments = useCallback((segmentsToEnable) => {
457+
const enableSegments = useCallback((segmentsToEnable: { segId: string }[]) => {
458458
if (segmentsToEnable.length === 0 || segmentsToEnable.length === cutSegments.length) return; // no point
459459
setDeselectedSegmentIds((existing) => {
460460
const ret = { ...existing };
@@ -471,13 +471,43 @@ function useSegments({ filePath, workingRef, setWorking, setCutProgress, videoSt
471471
enableSegments(segmentsToEnable);
472472
}, [currentCutSeg, cutSegments, enableSegments]);
473473

474-
const onSelectSegmentsByTag = useCallback(async () => {
475-
const value = await selectSegmentsByTagDialog();
474+
const onSelectSegmentsByExpr = useCallback(async () => {
475+
function matchSegment(seg: StateSegment, expr: string) {
476+
const start = getSegApparentStart(seg);
477+
const end = getSegApparentEnd(seg);
478+
// must clone tags because scope is mutable (editable by expression)
479+
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 } };
480+
return evaluate(expr, { segment: scopeSegment }) === true;
481+
}
482+
483+
const getSegmentsToEnable = (expr: string) => cutSegments.filter((seg) => {
484+
try {
485+
return matchSegment(seg, expr);
486+
} catch (err) {
487+
if (err instanceof TypeError) {
488+
return false;
489+
}
490+
throw err;
491+
}
492+
});
493+
494+
const value = await selectSegmentsByExprDialog((v: string) => {
495+
try {
496+
const segments = getSegmentsToEnable(v);
497+
if (segments.length === 0) return i18n.t('No segments matched');
498+
return undefined;
499+
} catch (err) {
500+
if (err instanceof Error) {
501+
return err.message;
502+
}
503+
throw err;
504+
}
505+
});
506+
476507
if (value == null) return;
477-
const { tagName, tagValue } = value;
478-
const segmentsToEnable = cutSegments.filter((seg) => getSegmentTags(seg)[tagName] === tagValue);
508+
const segmentsToEnable = getSegmentsToEnable(value);
479509
enableSegments(segmentsToEnable);
480-
}, [cutSegments, enableSegments]);
510+
}, [cutSegments, enableSegments, getSegApparentEnd]);
481511

482512
const onLabelSelectedSegments = useCallback(async () => {
483513
if (selectedSegmentsRaw.length === 0) return;
@@ -570,7 +600,7 @@ function useSegments({ filePath, workingRef, setWorking, setCutProgress, videoSt
570600
invertSelectedSegments,
571601
removeSelectedSegments,
572602
onSelectSegmentsByLabel,
573-
onSelectSegmentsByTag,
603+
onSelectSegmentsByExpr,
574604
toggleSegmentSelected,
575605
selectOnlySegment,
576606
setCutTime,

0 commit comments

Comments
 (0)