From 06cae06f4b4a497583acccb340e9e23b9874acb2 Mon Sep 17 00:00:00 2001 From: Mikael Finstad Date: Fri, 24 Jan 2025 17:58:40 +0800 Subject: [PATCH] allow editing segments by js expression closes #2128 #2305 --- expressions.md | 10 ++- src/renderer/src/App.tsx | 3 +- src/renderer/src/SegmentList.tsx | 8 +- src/renderer/src/dialogs/index.tsx | 56 ++++++++++--- src/renderer/src/hooks/useSegments.ts | 111 ++++++++++++++++++++------ types.ts | 1 + 6 files changed, 149 insertions(+), 40 deletions(-) diff --git a/expressions.md b/expressions.md index 3afa1bd5..bf09f0f0 100644 --- a/expressions.md +++ b/expressions.md @@ -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. \ No newline at end of file +## 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. diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index e63f8bb9..6699ebd3 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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} diff --git a/src/renderer/src/SegmentList.tsx b/src/renderer/src/SegmentList.tsx index 3cef8d8a..87285350 100644 --- a/src/renderer/src/SegmentList.tsx +++ b/src/renderer/src/SegmentList.tsx @@ -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, 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} diff --git a/src/renderer/src/dialogs/index.tsx b/src/renderer/src/dialogs/index.tsx index 754d5364..3d07b5d8 100644 --- a/src/renderer/src/dialogs/index.tsx +++ b/src/renderer/src/dialogs/index.tsx @@ -582,30 +582,29 @@ export async function selectSegmentsByLabelDialog(currentName: string) { return value; } -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 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, + examples: Record, + title: string, + description: ReactNode, + variables: string[] +}) { function addExample(type: string) { Swal.getInput()!.value = examples[type]?.code ?? ''; } const { value } = await ReactSwal.fire({ showCancelButton: true, - title: i18n.t('Select segments by expression'), + title, input: 'text', + width: '90vw', html: (
- Enter a JavaScript expression which will be evaluated for each segment. Segments for which the expression evaluates to "true" will be selected. + {description}
-
{i18n.t('Variables')}: segment.label, segment.start, segment.end, segment.duration, segment.tags.*
+
{i18n.t('Variables')}: {variables.join(', ')}
{i18n.t('Examples')}:
@@ -622,6 +621,39 @@ export async function selectSegmentsByExprDialog(inputValidator: (v: string) => return value; } +export async function selectSegmentsByExprDialog(inputValidator: (v: string) => Promise) { + 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: Enter a JavaScript expression which will be evaluated for each segment. Segments for which the expression evaluates to "true" will be selected. , + variables: ['segment.index', 'segment.label', 'segment.start', 'segment.end', 'segment.duration', 'segment.tags.*'], + }); +} + +export async function mutateSegmentsByExprDialog(inputValidator: (v: string) => Promise) { + 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: Enter a JavaScript expression which will be evaluated for each selected segment. Returned properties will be edited. , + 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 = ( diff --git a/src/renderer/src/hooks/useSegments.ts b/src/renderer/src/hooks/useSegments.ts index 2af57776..79432245 100644 --- a/src/renderer/src/hooks/useSegments.ts +++ b/src/renderer/src/hooks/useSegments.ts @@ -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, 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: (s: T) => Promise | 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: (s: T) => Promise | 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> = {}; + 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, diff --git a/types.ts b/types.ts index 34828ac2..94398460 100644 --- a/types.ts +++ b/types.ts @@ -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,