Skip to content

Commit

Permalink
allow selecting segments by expression
Browse files Browse the repository at this point in the history
also remove select by tags dialog to reduce code
it's covered by expression

fixes #1999
  • Loading branch information
mifi committed May 16, 2024
1 parent 58adc59 commit b5028dc
Show file tree
Hide file tree
Showing 6 changed files with 179 additions and 40 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@
"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",
Expand Down
4 changes: 2 additions & 2 deletions src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,7 @@ function App() {
}, [detectedFps, timecodeFormat, getFrameCount]);

const {
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,
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,
} = useSegments({ filePath, workingRef, setWorking, setCutProgress, videoStream: activeVideoStream, duration, getRelevantTime, maxLabelLength, checkFileOpened, invertCutSegments, segmentsToChaptersOnly, timecodePlaceholder, parseTimecode });


Expand Down Expand Up @@ -2637,7 +2637,7 @@ function App() {
jumpSegStart={jumpSegStart}
jumpSegEnd={jumpSegEnd}
onSelectSegmentsByLabel={onSelectSegmentsByLabel}
onSelectSegmentsByTag={onSelectSegmentsByTag}
onSelectSegmentsByExpr={onSelectSegmentsByExpr}
onLabelSelectedSegments={onLabelSelectedSegments}
updateSegAtIndex={updateSegAtIndex}
editingSegmentTags={editingSegmentTags}
Expand Down
14 changes: 7 additions & 7 deletions src/renderer/src/SegmentList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ const Segment = memo(({
onToggleSegmentSelected,
onDeselectAllSegments,
onSelectSegmentsByLabel,
onSelectSegmentsByTag,
onSelectSegmentsByExpr,
onSelectAllSegments,
jumpSegStart,
jumpSegEnd,
Expand All @@ -71,7 +71,7 @@ const Segment = memo(({
onToggleSegmentSelected: UseSegments['toggleSegmentSelected'],
onDeselectAllSegments: UseSegments['deselectAllSegments'],
onSelectSegmentsByLabel: UseSegments['onSelectSegmentsByLabel'],
onSelectSegmentsByTag: UseSegments['onSelectSegmentsByTag'],
onSelectSegmentsByExpr: UseSegments['onSelectSegmentsByExpr'],
onSelectAllSegments: UseSegments['selectAllSegments'],
jumpSegStart: (i: number) => void,
jumpSegEnd: (i: number) => void,
Expand Down Expand Up @@ -109,7 +109,7 @@ const Segment = memo(({
{ label: t('Select all segments'), click: () => onSelectAllSegments() },
{ label: t('Deselect all segments'), click: () => onDeselectAllSegments() },
{ label: t('Select segments by label'), click: () => onSelectSegmentsByLabel() },
{ label: t('Select segments by tag'), click: () => onSelectSegmentsByTag() },
{ label: t('Select segments by expression'), click: () => onSelectSegmentsByExpr() },
{ label: t('Invert selected segments'), click: () => onInvertSelectedSegments() },

{ type: 'separator' },
Expand All @@ -128,7 +128,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, onSelectSegmentsByTag, onInvertSelectedSegments, onReorderPress, onEditSegmentTags, onExtractSegmentFramesAsImages]);
}, [invertCutSegments, t, addSegment, onLabelSelectedSegments, onRemoveSelected, updateSegOrder, index, jumpSegStart, jumpSegEnd, onLabelPress, onRemovePress, onDuplicateSegmentClick, seg, onSelectSingleSegment, onSelectAllSegments, onDeselectAllSegments, onSelectSegmentsByLabel, onSelectSegmentsByExpr, onInvertSelectedSegments, onReorderPress, onEditSegmentTags, onExtractSegmentFramesAsImages]);

useContextMenu(ref, contextMenuTemplate);

Expand Down Expand Up @@ -243,7 +243,7 @@ const SegmentList = memo(({
onDeselectAllSegments,
onSelectAllSegments,
onSelectSegmentsByLabel,
onSelectSegmentsByTag,
onSelectSegmentsByExpr,
onExtractSegmentFramesAsImages,
onLabelSelectedSegments,
onInvertSelectedSegments,
Expand Down Expand Up @@ -281,7 +281,7 @@ const SegmentList = memo(({
onDeselectAllSegments: UseSegments['deselectAllSegments'],
onSelectAllSegments: UseSegments['selectAllSegments'],
onSelectSegmentsByLabel: UseSegments['onSelectSegmentsByLabel'],
onSelectSegmentsByTag: UseSegments['onSelectSegmentsByTag'],
onSelectSegmentsByExpr: UseSegments['onSelectSegmentsByExpr'],
onExtractSegmentFramesAsImages: (segIds: string[]) => Promise<void>,
onLabelSelectedSegments: UseSegments['onLabelSelectedSegments'],
onInvertSelectedSegments: UseSegments['invertSelectedSegments'],
Expand Down Expand Up @@ -487,7 +487,7 @@ const SegmentList = memo(({
onSelectAllSegments={onSelectAllSegments}
onEditSegmentTags={onEditSegmentTags}
onSelectSegmentsByLabel={onSelectSegmentsByLabel}
onSelectSegmentsByTag={onSelectSegmentsByTag}
onSelectSegmentsByExpr={onSelectSegmentsByExpr}
onExtractSegmentFramesAsImages={onExtractSegmentFramesAsImages}
onLabelSelectedSegments={onLabelSelectedSegments}
onInvertSelectedSegments={onInvertSelectedSegments}
Expand Down
65 changes: 44 additions & 21 deletions src/renderer/src/dialogs/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -434,7 +434,7 @@ export async function createFixedDurationSegments({ fileDuration, inputPlacehold
return edl;
}

export async function createRandomSegments(fileDuration) {
export async function createRandomSegments(fileDuration: number) {
const response = await askForSegmentsRandomDurationRange();
if (response == null) return undefined;

Expand All @@ -451,14 +451,14 @@ export async function createRandomSegments(fileDuration) {
return edl;
}

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

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

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

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

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

export async function selectSegmentsByTagDialog() {
const { value: value1 } = await Swal.fire({
showCancelButton: true,
title: i18n.t('Select segments by tag'),
text: i18n.t('Enter tag name (in the next dialog you\'ll enter tag value)'),
input: 'text',
});
if (!value1) return undefined;
export async function selectSegmentsByExprDialog(inputValidator: (v: string) => 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 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')" },
};

function addExample(type: string) {
Swal.getInput()!.value = examples[type]?.code ?? '';
}

const { value: value2 } = await Swal.fire({
const { value } = await ReactSwal.fire<string>({
showCancelButton: true,
title: i18n.t('Select segments by tag'),
text: i18n.t('Enter tag value'),
title: i18n.t('Select segments by expression'),
input: 'text',
});
if (!value2) return undefined;
html: (
<div style={{ textAlign: 'left' }}>
<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/' })}
</div>

return { tagName: value1, tagValue: value2 };
<div><b>{i18n.t('Variables')}:</b></div>

<div style={{ marginBottom: '1em' }}>
segment.label, segment.start, segment.end, segment.duration
</div>

<div><b>{i18n.t('Examples')}:</b></div>

{Object.entries(examples).map(([key, { name }]) => (
<button key={key} type="button" onClick={() => addExample(key)} className="button-unstyled" style={{ display: 'block', marginBottom: '.1em' }}>
{name}
</button>
))}
</div>
),
inputPlaceholder: 'segment.duration < 5',
inputValidator,
});
return value;
}

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

function parseValue(v) {
function parseValue(v: string) {
const newFps = parseFloat(v);
if (!Number.isNaN(newFps)) {
return newFps / fps;
Expand Down
50 changes: 40 additions & 10 deletions src/renderer/src/hooks/useSegments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@ 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';
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, selectSegmentsByTagDialog } from '../dialogs';
import { createSegment, findSegmentsAtCursor, sortSegments, invertSegments, getSegmentTags, combineOverlappingSegments as combineOverlappingSegments2, combineSelectedSegments as combineSelectedSegments2, isDurationValid, getSegApparentStart, getSegApparentEnd as getSegApparentEnd2, addSegmentColorIndex } from '../segments';
import { createNumSegments as createNumSegmentsDialog, createFixedDurationSegments as createFixedDurationSegmentsDialog, createRandomSegments as createRandomSegmentsDialog, labelSegmentDialog, askForShiftSegments, askForAlignSegments, selectSegmentsByLabelDialog, selectSegmentsByExprDialog } from '../dialogs';
import { createSegment, findSegmentsAtCursor, sortSegments, invertSegments, combineOverlappingSegments as combineOverlappingSegments2, combineSelectedSegments as combineSelectedSegments2, isDurationValid, getSegApparentStart, getSegApparentEnd as getSegApparentEnd2, addSegmentColorIndex } from '../segments';
import * as ffmpegParameters from '../ffmpeg-parameters';
import { maxSegmentsAllowed } from '../util/constants';
import { ParseTimecode, SegmentBase, SegmentToExport, StateSegment, UpdateSegAtIndex } from '../types';
Expand Down Expand Up @@ -454,7 +454,7 @@ function useSegments({ filePath, workingRef, setWorking, setCutProgress, videoSt
if (segments) loadCutSegments(segments);
}, [checkFileOpened, duration, loadCutSegments]);

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

const onSelectSegmentsByTag = useCallback(async () => {
const value = await selectSegmentsByTagDialog();
const onSelectSegmentsByExpr = useCallback(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<string, string> } = { label: seg.name, start, end, duration: end - start, tags: { ...seg.tags } };
return evaluate(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 value = await selectSegmentsByExprDialog((v: string) => {
try {
const segments = getSegmentsToEnable(v);
if (segments.length === 0) return i18n.t('No segments matched');
return undefined;
} catch (err) {
if (err instanceof Error) {
return err.message;
}
throw err;
}
});

if (value == null) return;
const { tagName, tagValue } = value;
const segmentsToEnable = cutSegments.filter((seg) => getSegmentTags(seg)[tagName] === tagValue);
const segmentsToEnable = getSegmentsToEnable(value);
enableSegments(segmentsToEnable);
}, [cutSegments, enableSegments]);
}, [cutSegments, enableSegments, getSegApparentEnd]);

const onLabelSelectedSegments = useCallback(async () => {
if (selectedSegmentsRaw.length === 0) return;
Expand Down Expand Up @@ -570,7 +600,7 @@ function useSegments({ filePath, workingRef, setWorking, setCutProgress, videoSt
invertSelectedSegments,
removeSelectedSegments,
onSelectSegmentsByLabel,
onSelectSegmentsByTag,
onSelectSegmentsByExpr,
toggleSegmentSelected,
selectOnlySegment,
setCutTime,
Expand Down
Loading

1 comment on commit b5028dc

@markmillerdev
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Thanks!

Please sign in to comment.