mirror of https://github.com/mifi/lossless-cut
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1037 lines
48 KiB
TypeScript
1037 lines
48 KiB
TypeScript
import type { MutableRefObject, FormEvent } from 'react';
|
|
import { useCallback, useRef, useMemo, useState, useEffect } from 'react';
|
|
import { useStateWithHistory } from 'react-use/lib/useStateWithHistory';
|
|
import i18n from 'i18next';
|
|
import pMap from 'p-map';
|
|
import invariant from 'tiny-invariant';
|
|
import sortBy from 'lodash/sortBy';
|
|
import { Trans, useTranslation } from 'react-i18next';
|
|
import { FaLink } from 'react-icons/fa';
|
|
|
|
import TextInput from '../components/TextInput';
|
|
import { detectSceneChanges as ffmpegDetectSceneChanges, readFrames, mapTimesToSegments, findKeyframeNearTime } from '../ffmpeg';
|
|
import { getFileSize, shuffleArray } from '../util';
|
|
import { errorToast } from '../swal';
|
|
import { createNumSegments as createNumSegmentsDialog, createFixedByteSixedSegments as createFixedByteSixedSegmentsDialog, createRandomSegments as createRandomSegmentsDialog, labelSegmentDialog, askForShiftSegments, askForAlignSegments, selectSegmentsByLabelDialog, askForSegmentDuration, toastError } from '../dialogs';
|
|
import { createSegment, sortSegments, invertSegments, combineOverlappingSegments as combineOverlappingSegments2, combineSelectedSegments as combineSelectedSegments2, isDurationValid, addSegmentColorIndex, filterNonMarkers, makeDurationSegments, isInitialSegment } from '../segments';
|
|
import type { FfmpegDialog } from '../ffmpegParameters';
|
|
import { parameters as allFfmpegParameters, getHint, getLabel } from '../ffmpegParameters';
|
|
import { maxSegmentsAllowed } from '../util/constants';
|
|
import type { DefiniteSegmentBase, ParseTimecode, SegmentBase, SegmentToExport, StateSegment, UpdateSegAtIndex } from '../types';
|
|
import { segmentTagsSchema } from '../types';
|
|
import safeishEval from '../worker/eval';
|
|
import type { FFprobeFormat, FFprobeStream } from '../../../common/ffprobe';
|
|
import type { HandleError } from '../contexts';
|
|
import type { ShowGenericDialog } from '../components/GenericDialog';
|
|
import { useGenericDialogContext } from '../components/GenericDialog';
|
|
import ExpressionDialog from '../components/ExpressionDialog';
|
|
import Button, { DialogButton } from '../components/Button';
|
|
import { ButtonRow } from '../components/Dialog';
|
|
import * as Dialog from '../components/Dialog';
|
|
import { UserFacingError } from '../../errors';
|
|
import { editSegmentByExpressionHelpUrl, selectSegmentByExpressionHelpUrl } from '../../../common/constants';
|
|
import type { Segment as ScopeSegment } from '../../../common/userTypes';
|
|
|
|
const remote = window.require('@electron/remote');
|
|
const { shell } = remote;
|
|
const { ffmpeg: { blackDetect, silenceDetect } } = remote.require('./index.js');
|
|
|
|
|
|
type ParameterDialogParameters = Record<string, string>;
|
|
|
|
const offsetSegments = (segments: DefiniteSegmentBase[], offset: number) => segments.map((s) => ({ start: s.start + offset, end: s.end + offset }));
|
|
|
|
function useSegments({ filePath, workingRef, setWorking, setProgress, videoStream, fileDuration, getRelevantTime, maxLabelLength, checkFileOpened, invertCutSegments, segmentsToChaptersOnly, timecodePlaceholder, parseTimecode, appendFfmpegCommandLog, fileDurationNonZero, mainFileMeta, seekAbs, activeVideoStreamIndex, activeAudioStreamIndexes, handleError, showGenericDialog, simpleMode }: {
|
|
filePath?: string | undefined,
|
|
workingRef: MutableRefObject<boolean>,
|
|
setWorking: (w: { text: string, abortController?: AbortController } | undefined) => void,
|
|
setProgress: (a: number | undefined) => void,
|
|
videoStream: FFprobeStream | undefined,
|
|
fileDuration?: number | undefined,
|
|
getRelevantTime: () => number,
|
|
maxLabelLength: number,
|
|
checkFileOpened: () => boolean,
|
|
invertCutSegments: boolean,
|
|
segmentsToChaptersOnly: boolean,
|
|
timecodePlaceholder: string,
|
|
parseTimecode: ParseTimecode,
|
|
appendFfmpegCommandLog: (args: string[]) => void,
|
|
fileDurationNonZero: number,
|
|
mainFileMeta: { format: FFprobeFormat } | undefined,
|
|
seekAbs: (val: number | undefined) => void,
|
|
activeVideoStreamIndex: number | undefined,
|
|
activeAudioStreamIndexes: Set<number>,
|
|
handleError: HandleError,
|
|
showGenericDialog: ShowGenericDialog,
|
|
simpleMode: boolean,
|
|
}) {
|
|
const { t } = useTranslation();
|
|
|
|
// Segment related state
|
|
const [segColorCounter, setSegColorCounterState] = useState(0);
|
|
const segColorCounterRef = useRef(0);
|
|
const setSegColorCounter = useCallback((v: number) => {
|
|
segColorCounterRef.current = v;
|
|
setSegColorCounterState(v);
|
|
}, []);
|
|
|
|
const createIndexedSegment = useCallback(({ segment, incrementCount }: {
|
|
segment?: Parameters<typeof createSegment>[0],
|
|
incrementCount?: boolean,
|
|
} = {}) => {
|
|
if (incrementCount) {
|
|
setSegColorCounter(segColorCounterRef.current + 1);
|
|
}
|
|
return addSegmentColorIndex(createSegment(segment), segColorCounterRef.current);
|
|
}, [setSegColorCounter]);
|
|
|
|
const [cutSegments, setCutSegments, cutSegmentsHistory] = useStateWithHistory<StateSegment[], StateSegment[]>(
|
|
[],
|
|
100,
|
|
);
|
|
|
|
const [currentSegIndex, setCurrentSegIndex] = useState(0);
|
|
|
|
const [ffmpegParameters, setFfmpegParameters] = useState(() => Object.fromEntries(Object.entries(allFfmpegParameters).map(([dialogType, parameters]) => ([
|
|
dialogType,
|
|
Object.fromEntries(Object.entries(parameters).map(([k2, v2]) => [k2, v2.value])),
|
|
] as const))));
|
|
|
|
const setFfmpegParametersForDialog = useCallback((dialogType: FfmpegDialog, newParams: Record<string, string>) => setFfmpegParameters((existing) => ({
|
|
...existing,
|
|
[dialogType]: {
|
|
...existing[dialogType],
|
|
...newParams,
|
|
},
|
|
})), []);
|
|
|
|
|
|
const clearSegColorCounter = useCallback(() => {
|
|
// eslint-disable-next-line no-param-reassign
|
|
setSegColorCounter(0);
|
|
}, [setSegColorCounter]);
|
|
|
|
const safeSetCutSegments = useCallback((newSegmentsOrFn: StateSegment[] | ((a: StateSegment[]) => StateSegment[]), clampDuration?: number) => {
|
|
function clampValue(val: number | undefined) {
|
|
if (val == null || Number.isNaN(val)) return undefined;
|
|
const clamped = Math.max(val, 0);
|
|
if (clampDuration == null) return clamped;
|
|
return Math.min(clamped, clampDuration);
|
|
}
|
|
|
|
// delete "initial" after modifying segments
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
const map = (newSegments: StateSegment[]) => newSegments.flatMap(({ start, end, initial: _ignored, ...rest }) => {
|
|
const startClamped = clampValue(start) ?? 0;
|
|
const endClamped = clampValue(end);
|
|
|
|
// convert 0 length segments into a markers
|
|
if (endClamped == null || endClamped <= startClamped) {
|
|
return [{
|
|
...rest,
|
|
start: startClamped,
|
|
}];
|
|
}
|
|
|
|
return [{
|
|
...rest,
|
|
start: startClamped,
|
|
end: endClamped,
|
|
}];
|
|
});
|
|
|
|
if (typeof newSegmentsOrFn === 'function') {
|
|
setCutSegments((existing) => map(newSegmentsOrFn(existing)));
|
|
return;
|
|
}
|
|
|
|
setCutSegments(map(newSegmentsOrFn));
|
|
}, [setCutSegments]);
|
|
|
|
const clearSegments = useCallback(() => {
|
|
clearSegColorCounter();
|
|
safeSetCutSegments([]);
|
|
}, [clearSegColorCounter, safeSetCutSegments]);
|
|
|
|
const shuffleSegments = useCallback(() => safeSetCutSegments((existingSegments) => [
|
|
...existingSegments.filter((s) => !s.selected),
|
|
...shuffleArray(existingSegments.filter((s) => s.selected)),
|
|
]), [safeSetCutSegments]);
|
|
|
|
// todo combine with safeSetCutSegments?
|
|
const loadCutSegments = useCallback(({ segments, append, clampDuration, getNextCurrentSegIndex }: {
|
|
segments: SegmentBase[],
|
|
append: boolean,
|
|
clampDuration?: number | undefined,
|
|
getNextCurrentSegIndex?: (newEdl: SegmentBase[]) => number,
|
|
}) => {
|
|
if (segments.length === 0) throw new UserFacingError(i18n.t('No valid segments found'));
|
|
|
|
if (segments.length > maxSegmentsAllowed) throw new UserFacingError(i18n.t('Tried to create too many segments (max {{maxSegmentsAllowed}}.)', { maxSegmentsAllowed }));
|
|
|
|
if (!append) clearSegColorCounter();
|
|
|
|
safeSetCutSegments((existingSegments) => {
|
|
const needToAppend = append && !isInitialSegment(existingSegments);
|
|
let newSegments = segments.map((segment, i) => createIndexedSegment({ segment, incrementCount: needToAppend || i > 0 }));
|
|
if (needToAppend) newSegments = [...existingSegments, ...newSegments];
|
|
if (getNextCurrentSegIndex) setCurrentSegIndex(getNextCurrentSegIndex(newSegments));
|
|
return newSegments;
|
|
}, clampDuration);
|
|
}, [clearSegColorCounter, createIndexedSegment, safeSetCutSegments]);
|
|
|
|
const detectSegments = useCallback(async ({ name, workingText, errorText, fn }: {
|
|
name: string,
|
|
workingText: string,
|
|
errorText: string,
|
|
fn: (onSegmentDetected: (seg: SegmentBase) => void) => Promise<{ ffmpegArgs: string[] }>,
|
|
}) => {
|
|
if (!filePath) return;
|
|
if (workingRef.current) return;
|
|
try {
|
|
setWorking({ text: workingText });
|
|
setProgress(0);
|
|
|
|
// todo throttle?
|
|
const { ffmpegArgs } = await fn((detectedSegment) => {
|
|
console.log('Detected', name, detectedSegment);
|
|
loadCutSegments({ segments: [detectedSegment], append: true, getNextCurrentSegIndex: (edl) => edl.length - 1, clampDuration: fileDuration });
|
|
seekAbs(detectedSegment.start);
|
|
});
|
|
appendFfmpegCommandLog(ffmpegArgs);
|
|
} catch (err) {
|
|
if (!(err instanceof Error && err.name === 'AbortError')) handleError({ err, title: errorText });
|
|
} finally {
|
|
setWorking(undefined);
|
|
setProgress(undefined);
|
|
}
|
|
}, [filePath, workingRef, setWorking, setProgress, appendFfmpegCommandLog, loadCutSegments, fileDuration, seekAbs, handleError]);
|
|
|
|
const getScopeSegment = useCallback((seg: Pick<StateSegment, 'name' | 'start' | 'end' | 'tags'>, index: number): ScopeSegment => {
|
|
const { start, end, name, tags } = seg;
|
|
// must clone tags because scope is mutable (editable by expression)
|
|
return { index, label: name, start, end, duration: end != null ? end - start : 0, tags: { ...tags } };
|
|
}, []);
|
|
|
|
const haveInvalidSegs = useMemo(() => cutSegments.some((cutSegment) => cutSegment.end != null && cutSegment.start >= cutSegment.end), [cutSegments]);
|
|
|
|
const currentSegIndexSafe = Math.min(currentSegIndex, cutSegments.length - 1);
|
|
|
|
const currentCutSeg = useMemo(() => cutSegments[currentSegIndexSafe], [currentSegIndexSafe, cutSegments]);
|
|
|
|
const deleteCurrentCutSeg = useCallback(() => {
|
|
if (currentCutSeg == null) return;
|
|
safeSetCutSegments((existing) => existing.filter((s) => s !== currentCutSeg));
|
|
}, [currentCutSeg, safeSetCutSegments]);
|
|
|
|
const currentCutSegOrWholeTimeline = useMemo(() => {
|
|
const { start = 0, end = fileDurationNonZero } = currentCutSeg ?? {};
|
|
return { start, end, duration: end - start };
|
|
}, [currentCutSeg, fileDurationNonZero]);
|
|
|
|
const selectedSegments = useMemo(() => cutSegments.flatMap((segment, i) => (segment.selected ? [{ ...segment, originalIndex: i }] : [])), [cutSegments]);
|
|
|
|
const getFfmpegParameters = useCallback((key: FfmpegDialog) => {
|
|
const parameters = ffmpegParameters[key];
|
|
invariant(parameters);
|
|
return parameters;
|
|
}, [ffmpegParameters]);
|
|
|
|
const showParametersDialog = useCallback(async ({ title, description, dialogType, parameters: parametersIn, docUrl }: {
|
|
title?: string,
|
|
description?: string,
|
|
dialogType: FfmpegDialog,
|
|
parameters: ParameterDialogParameters,
|
|
docUrl?: string,
|
|
}) => new Promise<ParameterDialogParameters | undefined>((resolve) => {
|
|
function ParametersDialog() {
|
|
const { onOpenChange } = useGenericDialogContext();
|
|
|
|
const firstInputRef = useRef<HTMLInputElement>(null);
|
|
const [parameters, setParameters] = useState(parametersIn);
|
|
|
|
const getParameter = (key: string) => parameters[key];
|
|
|
|
const handleChange = (key: string, value: string) => setParameters((existing) => ({ ...existing, [key]: value }));
|
|
|
|
const handleSubmit = useCallback((e: FormEvent<HTMLFormElement>) => {
|
|
e.preventDefault();
|
|
resolve(parameters);
|
|
onOpenChange(false);
|
|
}, [onOpenChange, parameters]);
|
|
|
|
useEffect(() => {
|
|
firstInputRef.current?.focus();
|
|
}, []);
|
|
|
|
return (
|
|
<Dialog.Content aria-describedby={undefined} style={{ width: '80vw' }}>
|
|
<Dialog.Title>{title}</Dialog.Title>
|
|
|
|
<Dialog.Description>{description}</Dialog.Description>
|
|
|
|
{docUrl && <p><Button onClick={() => shell.openExternal(docUrl)}><FaLink style={{ fontSize: '.8em' }} /> {t('Read more')}</Button></p>}
|
|
|
|
<form onSubmit={handleSubmit}>
|
|
{Object.entries(parametersIn).map(([key, parameter], i) => {
|
|
const id = `parameter-${key}`;
|
|
return (
|
|
<div key={key} style={{ marginBottom: '.5em' }}>
|
|
<label htmlFor={id} style={{ display: 'block', fontFamily: 'monospace', marginBottom: '.3em' }}>{getLabel(dialogType, parameter) || key}</label>
|
|
<TextInput
|
|
id={id}
|
|
ref={i === 0 ? firstInputRef : undefined}
|
|
value={getParameter(key)}
|
|
onChange={(e) => handleChange(key, e.target.value)}
|
|
style={{ marginBottom: '.2em' }}
|
|
/>
|
|
{getHint(dialogType, key) && <div style={{ opacity: 0.6, fontSize: '0.8em' }}>{getHint(dialogType, key)}</div>}
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
<ButtonRow>
|
|
<Dialog.Close asChild>
|
|
<DialogButton>{t('Cancel')}</DialogButton>
|
|
</Dialog.Close>
|
|
|
|
<DialogButton type="submit" primary>{t('Confirm')}</DialogButton>
|
|
</ButtonRow>
|
|
</form>
|
|
</Dialog.Content>
|
|
);
|
|
}
|
|
|
|
showGenericDialog({
|
|
render: () => <ParametersDialog />,
|
|
onClose: () => resolve(undefined),
|
|
});
|
|
}), [showGenericDialog, t]);
|
|
|
|
const detectBlackScenes = useCallback(async () => {
|
|
const { start, end } = currentCutSegOrWholeTimeline;
|
|
deleteCurrentCutSeg();
|
|
const dialogType = 'blackdetect';
|
|
const parameters = await showParametersDialog({ title: i18n.t('Enter parameters'), dialogType, parameters: getFfmpegParameters(dialogType), docUrl: 'https://ffmpeg.org/ffmpeg-filters.html#blackdetect' });
|
|
if (parameters == null) return;
|
|
const { mode, ...filterOptions } = parameters;
|
|
setFfmpegParametersForDialog(dialogType, parameters);
|
|
invariant(mode === '1' || mode === '2');
|
|
invariant(filePath != null);
|
|
await detectSegments({ name: 'blackScenes', workingText: i18n.t('Detecting black scenes'), errorText: i18n.t('Failed to detect black scenes'), fn: async (onSegmentDetected) => blackDetect({ filePath, streamId: activeVideoStreamIndex, filterOptions, boundingMode: mode === '1', onProgress: setProgress, onSegmentDetected, from: start, to: end }) });
|
|
}, [currentCutSegOrWholeTimeline, deleteCurrentCutSeg, showParametersDialog, getFfmpegParameters, setFfmpegParametersForDialog, filePath, detectSegments, activeVideoStreamIndex, setProgress]);
|
|
|
|
const detectSilentScenes = useCallback(async () => {
|
|
const { start, end } = currentCutSegOrWholeTimeline;
|
|
deleteCurrentCutSeg();
|
|
const dialogType = 'silencedetect';
|
|
const parameters = await showParametersDialog({ title: i18n.t('Enter parameters'), dialogType, parameters: getFfmpegParameters(dialogType), docUrl: 'https://ffmpeg.org/ffmpeg-filters.html#silencedetect' });
|
|
if (parameters == null) return;
|
|
setFfmpegParametersForDialog(dialogType, parameters);
|
|
const { mode, ...filterOptions } = parameters;
|
|
invariant(mode === '1' || mode === '2');
|
|
invariant(filePath != null);
|
|
await detectSegments({ name: 'silentScenes', workingText: i18n.t('Detecting silent scenes'), errorText: i18n.t('Failed to detect silent scenes'), fn: async (onSegmentDetected) => silenceDetect({ filePath, streamId: [...activeAudioStreamIndexes][0], filterOptions, boundingMode: mode === '1', onProgress: setProgress, onSegmentDetected, from: start, to: end }) });
|
|
}, [activeAudioStreamIndexes, currentCutSegOrWholeTimeline, deleteCurrentCutSeg, detectSegments, filePath, getFfmpegParameters, setFfmpegParametersForDialog, setProgress, showParametersDialog]);
|
|
|
|
const detectSceneChanges = useCallback(async () => {
|
|
const { start, end } = currentCutSegOrWholeTimeline;
|
|
deleteCurrentCutSeg();
|
|
const dialogType = 'sceneChange';
|
|
const parameters = await showParametersDialog({ title: i18n.t('Enter parameters'), dialogType, parameters: getFfmpegParameters(dialogType) });
|
|
if (parameters == null) return;
|
|
setFfmpegParametersForDialog(dialogType, parameters);
|
|
invariant(filePath != null);
|
|
// eslint-disable-next-line prefer-destructuring
|
|
const minChange = parameters['minChange'];
|
|
invariant(minChange != null);
|
|
await detectSegments({ name: 'sceneChanges', workingText: i18n.t('Detecting scene changes'), errorText: i18n.t('Failed to detect scene changes'), fn: async (onSegmentDetected) => ffmpegDetectSceneChanges({ filePath, streamId: activeVideoStreamIndex, minChange, onProgress: setProgress, onSegmentDetected, from: start, to: end }) });
|
|
}, [activeVideoStreamIndex, currentCutSegOrWholeTimeline, deleteCurrentCutSeg, detectSegments, filePath, getFfmpegParameters, setFfmpegParametersForDialog, setProgress, showParametersDialog]);
|
|
|
|
const createSegmentsFromKeyframes = useCallback(async () => {
|
|
const { start, end } = currentCutSegOrWholeTimeline;
|
|
deleteCurrentCutSeg();
|
|
if (!videoStream) return;
|
|
invariant(filePath != null);
|
|
const keyframes = (await readFrames({ filePath, from: start, to: end, streamIndex: videoStream.index })).filter((frame) => frame.keyframe);
|
|
const newSegments = mapTimesToSegments(keyframes.map((keyframe) => keyframe.time), true);
|
|
loadCutSegments({ segments: newSegments, append: true, getNextCurrentSegIndex: (edl) => edl.length - 1, clampDuration: fileDuration });
|
|
}, [currentCutSegOrWholeTimeline, deleteCurrentCutSeg, fileDuration, filePath, loadCutSegments, videoStream]);
|
|
|
|
const removeSegments = useCallback((removeSegmentIds: string[]) => {
|
|
safeSetCutSegments((existingSegments) => {
|
|
const newSegments = existingSegments.filter((seg) => !removeSegmentIds.includes(seg.segId));
|
|
if (newSegments.length === 0) {
|
|
// when removing the last segments, we start over
|
|
clearSegColorCounter();
|
|
}
|
|
return newSegments;
|
|
});
|
|
}, [clearSegColorCounter, safeSetCutSegments]);
|
|
|
|
const removeSegment = useCallback((index: number, wholeSegment?: true) => {
|
|
const seg = cutSegments[index];
|
|
if (seg == null) return;
|
|
if (wholeSegment || seg.end == null) {
|
|
// remove whole segment
|
|
removeSegments([seg.segId]);
|
|
} else {
|
|
// remove end cut point first
|
|
safeSetCutSegments((existingSegments) => existingSegments.map((existingSegment, i) => (
|
|
i === index ? {
|
|
...existingSegment,
|
|
end: undefined,
|
|
} : existingSegment)));
|
|
}
|
|
}, [cutSegments, removeSegments, safeSetCutSegments]);
|
|
|
|
const inverseCutSegments = useMemo(() => {
|
|
if (haveInvalidSegs || !isDurationValid(fileDuration)) return [];
|
|
|
|
// exclude segments that don't have a length (markers)
|
|
// also exclude initial segment (will cause problems later on)
|
|
const sortedSegments = sortSegments(filterNonMarkers(cutSegments).filter((seg) => !seg.initial));
|
|
|
|
return invertSegments(sortedSegments, true, true, fileDuration).map(({ segId, end, ...rest }) => {
|
|
// in order to please TS:
|
|
invariant(segId != null && end != null);
|
|
return {
|
|
segId,
|
|
end,
|
|
...rest,
|
|
};
|
|
});
|
|
}, [cutSegments, fileDuration, haveInvalidSegs]);
|
|
|
|
const invertAllSegments = useCallback(() => {
|
|
// todo leave non selected segments as is?
|
|
// treat markers as 0 length
|
|
const sortedSegments = sortSegments(selectedSegments);
|
|
|
|
const inverseSegmentsAndMarkers = invertSegments(sortedSegments, true, true, fileDuration);
|
|
|
|
if (inverseSegmentsAndMarkers.length === 0) {
|
|
errorToast(i18n.t('Make sure you have no overlapping segments.'));
|
|
return;
|
|
}
|
|
// preserve segColorIndex (which represent colors) when inverting
|
|
const newInverseCutSegments = inverseSegmentsAndMarkers.map((inverseSegment, index) => addSegmentColorIndex(createSegment(inverseSegment), index));
|
|
safeSetCutSegments(newInverseCutSegments, fileDuration);
|
|
}, [fileDuration, selectedSegments, safeSetCutSegments]);
|
|
|
|
const fillSegmentsGaps = useCallback(() => {
|
|
// treat markers as 0 length
|
|
const sortedSegments = sortSegments(selectedSegments.map(({ end, ...rest }) => ({ ...rest, end: end ?? rest.start })));
|
|
|
|
const inverseSegmentsAndMarkers = invertSegments(sortedSegments, true, true, fileDuration);
|
|
|
|
if (inverseSegmentsAndMarkers.length === 0) {
|
|
errorToast(i18n.t('Make sure you have no overlapping segments.'));
|
|
return;
|
|
}
|
|
const newInverseCutSegments = inverseSegmentsAndMarkers.map((segment) => createIndexedSegment({ segment, incrementCount: true }));
|
|
safeSetCutSegments((existing) => ([...existing, ...newInverseCutSegments]));
|
|
}, [createIndexedSegment, fileDuration, selectedSegments, safeSetCutSegments]);
|
|
|
|
const combineOverlappingSegments = useCallback(() => {
|
|
safeSetCutSegments((existingSegments) => [
|
|
...existingSegments.filter((s) => !s.selected),
|
|
...combineOverlappingSegments2(existingSegments.filter((s) => s.selected)), // only process selected
|
|
]);
|
|
}, [safeSetCutSegments]);
|
|
|
|
const combineSelectedSegments = useCallback(() => {
|
|
safeSetCutSegments((existingSegments) => combineSelectedSegments2(existingSegments));
|
|
}, [safeSetCutSegments]);
|
|
|
|
const updateSegAtIndex = useCallback<UpdateSegAtIndex>((index, newProps) => {
|
|
if (index < 0) return;
|
|
const cutSegmentsNew = [...cutSegments];
|
|
const existing = cutSegments[index];
|
|
invariant(existing != null);
|
|
cutSegmentsNew.splice(index, 1, { ...existing, ...newProps });
|
|
safeSetCutSegments(cutSegmentsNew, fileDuration);
|
|
}, [cutSegments, safeSetCutSegments, fileDuration]);
|
|
|
|
const setCutTime = useCallback((type: 'start' | 'end' | 'move', time: number | undefined) => {
|
|
if (!isDurationValid(fileDuration) || currentCutSeg == null) return;
|
|
|
|
const clampStart = (start: number) => Math.min(Math.max(start, 0), fileDuration);
|
|
const clampEnd = (end?: number | undefined) => (end != null ? Math.min(Math.max(end, 0), fileDuration) : undefined);
|
|
|
|
if (type === 'start') {
|
|
invariant(time != null);
|
|
if (currentCutSeg.end != null && time >= currentCutSeg.end) {
|
|
throw new UserFacingError(i18n.t('Segment start time must precede end time'));
|
|
}
|
|
updateSegAtIndex(currentSegIndexSafe, { start: clampStart(time) });
|
|
}
|
|
if (type === 'end') {
|
|
if (time != null && time <= currentCutSeg.start) {
|
|
throw new UserFacingError(i18n.t('Segment start time must precede end time'));
|
|
}
|
|
updateSegAtIndex(currentSegIndexSafe, { end: clampEnd(time) });
|
|
}
|
|
if (type === 'move') {
|
|
invariant(time != null);
|
|
updateSegAtIndex(currentSegIndexSafe, {
|
|
start: clampStart(time),
|
|
...(currentCutSeg.end != null && { end: clampEnd(time + (currentCutSeg.end - currentCutSeg.start)) }),
|
|
});
|
|
}
|
|
}, [currentSegIndexSafe, currentCutSeg, fileDuration, updateSegAtIndex]);
|
|
|
|
const modifySelectedSegmentTimes = useCallback(async (transformSegment: <T extends SegmentBase>(s: T) => Promise<T> | T, concurrency = 5) => {
|
|
const newSegments = await pMap(cutSegments, async (segment) => {
|
|
if (!segment.selected) return segment; // pass thru non-selected segments
|
|
|
|
return transformSegment(segment);
|
|
}, { concurrency });
|
|
|
|
safeSetCutSegments(newSegments, fileDuration);
|
|
}, [cutSegments, fileDuration, safeSetCutSegments]);
|
|
|
|
const shiftAllSegmentTimes = useCallback(async () => {
|
|
const shift = await askForShiftSegments({ inputPlaceholder: timecodePlaceholder, parseTimecode });
|
|
if (shift == null) return;
|
|
|
|
const { shiftAmount, shiftKeys } = shift;
|
|
await modifySelectedSegmentTimes((segment) => {
|
|
const newSegment = { ...segment };
|
|
shiftKeys.forEach((key) => {
|
|
if (newSegment[key] != null) newSegment[key] += shiftAmount;
|
|
});
|
|
return newSegment;
|
|
});
|
|
}, [modifySelectedSegmentTimes, parseTimecode, timecodePlaceholder]);
|
|
|
|
const alignSegmentTimesToKeyframes = useCallback(async () => {
|
|
if (!videoStream || workingRef.current) return;
|
|
try {
|
|
const response = await askForAlignSegments();
|
|
if (response == null) return;
|
|
setWorking({ text: i18n.t('Aligning segments to keyframes') });
|
|
const { mode, startOrEnd } = response;
|
|
await modifySelectedSegmentTimes(async (segment) => {
|
|
const newSegment = { ...segment };
|
|
|
|
const align = async (key: 'start' | 'end') => {
|
|
const time = newSegment[key];
|
|
invariant(filePath != null);
|
|
if (time != null) {
|
|
const keyframe = await findKeyframeNearTime({ filePath, streamIndex: videoStream.index, time, mode });
|
|
if (keyframe == null) throw new UserFacingError(i18n.t('Cannot find any keyframe within 60 seconds of frame {{time}}', { time }));
|
|
newSegment[key] = keyframe;
|
|
}
|
|
};
|
|
if (startOrEnd.includes('start')) await align('start');
|
|
if (startOrEnd.includes('end')) await align('end');
|
|
return newSegment;
|
|
});
|
|
} catch (err) {
|
|
handleError({ err });
|
|
} finally {
|
|
setWorking(undefined);
|
|
}
|
|
}, [videoStream, workingRef, setWorking, modifySelectedSegmentTimes, filePath, handleError]);
|
|
|
|
const updateSegOrder = useCallback((index: number, newOrder: number) => {
|
|
if (newOrder > cutSegments.length - 1 || newOrder < 0) return;
|
|
const newSegments = [...cutSegments];
|
|
const removedSeg = newSegments.splice(index, 1)[0];
|
|
invariant(removedSeg != null);
|
|
newSegments.splice(newOrder, 0, removedSeg);
|
|
safeSetCutSegments(newSegments);
|
|
setCurrentSegIndex(newOrder);
|
|
}, [cutSegments, setCurrentSegIndex, safeSetCutSegments]);
|
|
|
|
const updateSegOrders = useCallback((newOrders: string[]) => {
|
|
const newSegments = sortBy(cutSegments, (seg) => newOrders.indexOf(seg.segId));
|
|
safeSetCutSegments(newSegments);
|
|
if (currentCutSeg != null) {
|
|
const newCurrentSegIndex = newOrders.indexOf(currentCutSeg.segId);
|
|
if (newCurrentSegIndex !== -1 && newCurrentSegIndex < newSegments.length) setCurrentSegIndex(newCurrentSegIndex);
|
|
}
|
|
}, [cutSegments, safeSetCutSegments, currentCutSeg, setCurrentSegIndex]);
|
|
|
|
const reorderSegsByStartTime = useCallback(() => {
|
|
safeSetCutSegments(sortBy(cutSegments, (seg) => seg.start));
|
|
}, [cutSegments, safeSetCutSegments]);
|
|
|
|
const addSegment = useCallback(() => {
|
|
try {
|
|
const suggestedStart = getRelevantTime();
|
|
/* if (keyframeCut) {
|
|
const keyframeAlignedStart = getSafeCutTime(suggestedStart, true);
|
|
if (keyframeAlignedStart != null) suggestedStart = keyframeAlignedStart;
|
|
} */
|
|
|
|
if (fileDuration == null || suggestedStart >= fileDuration) return;
|
|
|
|
const initial = isInitialSegment(cutSegments);
|
|
|
|
const suggestedEnd = simpleMode ? Math.min(suggestedStart + 10, fileDuration) : undefined;
|
|
const newSegment = createIndexedSegment({ segment: { start: suggestedStart, end: suggestedEnd }, incrementCount: !initial });
|
|
|
|
// if initial segment, replace it instead
|
|
const cutSegmentsNew = initial
|
|
? [newSegment]
|
|
: [...cutSegments, newSegment];
|
|
|
|
safeSetCutSegments(cutSegmentsNew, fileDuration);
|
|
setCurrentSegIndex(cutSegmentsNew.length - 1);
|
|
} catch (err) {
|
|
console.error(err);
|
|
}
|
|
}, [getRelevantTime, fileDuration, cutSegments, simpleMode, createIndexedSegment, safeSetCutSegments]);
|
|
|
|
const duplicateSegment = useCallback((segment: Pick<StateSegment, 'start' | 'end'> & Partial<Pick<StateSegment, 'name'>>) => {
|
|
try {
|
|
// Cannot duplicate if seg is not finished
|
|
if (segment.start === undefined && segment.end === undefined) return;
|
|
|
|
const cutSegmentsNew = [
|
|
...cutSegments,
|
|
createIndexedSegment({ segment: { start: segment.start, end: segment.end, name: segment.name }, incrementCount: true }),
|
|
];
|
|
|
|
safeSetCutSegments(cutSegmentsNew);
|
|
setCurrentSegIndex(cutSegmentsNew.length - 1);
|
|
} catch (err) {
|
|
console.error(err);
|
|
}
|
|
}, [createIndexedSegment, cutSegments, safeSetCutSegments]);
|
|
|
|
const duplicateCurrentSegment = useCallback(() => {
|
|
if (currentCutSeg == null) return;
|
|
duplicateSegment(currentCutSeg);
|
|
}, [currentCutSeg, duplicateSegment]);
|
|
|
|
const setCutStart = useCallback(() => {
|
|
if (!checkFileOpened()) return;
|
|
|
|
const relevantTime = getRelevantTime();
|
|
// https://github.com/mifi/lossless-cut/issues/168
|
|
// If current time is after the end of the current segment in the timeline, or there is no segment,
|
|
// conveniently add a new segment that starts at playerTime
|
|
if (currentCutSeg == null || (currentCutSeg.end != null && relevantTime >= currentCutSeg.end)) {
|
|
addSegment();
|
|
} else {
|
|
try {
|
|
const startTime = relevantTime;
|
|
/* if (keyframeCut) {
|
|
const keyframeAlignedCutTo = getSafeCutTime(startTime, true);
|
|
if (keyframeAlignedCutTo != null) startTime = keyframeAlignedCutTo;
|
|
} */
|
|
setCutTime('start', startTime);
|
|
} catch (err) {
|
|
toastError(err);
|
|
}
|
|
}
|
|
}, [checkFileOpened, getRelevantTime, currentCutSeg, addSegment, setCutTime]);
|
|
|
|
const setCutEnd = useCallback(() => {
|
|
if (!checkFileOpened()) return;
|
|
|
|
try {
|
|
const endTime = getRelevantTime();
|
|
|
|
/* if (keyframeCut) {
|
|
const keyframeAlignedCutTo = getSafeCutTime(endTime, false);
|
|
if (keyframeAlignedCutTo != null) endTime = keyframeAlignedCutTo;
|
|
} */
|
|
setCutTime('end', endTime);
|
|
} catch (err) {
|
|
toastError(err);
|
|
}
|
|
}, [checkFileOpened, getRelevantTime, setCutTime]);
|
|
|
|
const labelSegment = useCallback(async (index: number) => {
|
|
const seg = cutSegments[index];
|
|
if (seg == null) return;
|
|
const { name } = seg;
|
|
const value = await labelSegmentDialog({ currentName: name, maxLength: maxLabelLength });
|
|
if (value != null) updateSegAtIndex(index, { name: value });
|
|
}, [cutSegments, updateSegAtIndex, maxLabelLength]);
|
|
|
|
const selectSegments = useCallback((segmentsToSelect: { segId: string }[]) => {
|
|
const segIdsToSelect = new Set(segmentsToSelect.map(({ segId }) => segId));
|
|
if (segIdsToSelect.size === 0) return; // no point in selecting none
|
|
setCutSegments((existing) => existing.map(({ selected, ...segment }) => ({
|
|
...segment,
|
|
selected: selected || segIdsToSelect.has(segment.segId),
|
|
})));
|
|
}, [setCutSegments]);
|
|
|
|
const findSegmentsAtCursor = useCallback((currentTime: number) => (
|
|
cutSegments.flatMap((segment, index) => {
|
|
if (
|
|
(segment.start <= currentTime)
|
|
&& (segment.end != null && segment.end >= currentTime)
|
|
) {
|
|
return [index];
|
|
}
|
|
return [];
|
|
}).reverse() // reverse, so that if we are on multiple, we select the last first. That way, auto play selected segments won't go on repeat on the same segment
|
|
), [cutSegments]);
|
|
|
|
const focusSegmentAtCursor = useCallback(() => {
|
|
const relevantTime = getRelevantTime();
|
|
const [firstSegmentAtCursorIndex] = findSegmentsAtCursor(relevantTime);
|
|
if (firstSegmentAtCursorIndex == null) return;
|
|
setCurrentSegIndex(firstSegmentAtCursorIndex);
|
|
}, [findSegmentsAtCursor, getRelevantTime]);
|
|
|
|
const selectSegmentsAtCursor = useCallback(() => {
|
|
const relevantTime = getRelevantTime();
|
|
selectSegments(findSegmentsAtCursor(relevantTime).flatMap((index) => (cutSegments[index] ? [cutSegments[index]] : [])));
|
|
}, [cutSegments, findSegmentsAtCursor, getRelevantTime, selectSegments]);
|
|
|
|
const splitCurrentSegment = useCallback(() => {
|
|
const relevantTime = getRelevantTime();
|
|
const segmentsAtCursorIndexes = findSegmentsAtCursor(relevantTime);
|
|
|
|
const firstSegmentAtCursorIndex = segmentsAtCursorIndexes[0];
|
|
|
|
if (firstSegmentAtCursorIndex == null) {
|
|
errorToast(i18n.t('No segment to split. Please move cursor over the segment you want to split'));
|
|
return;
|
|
}
|
|
|
|
const segment = cutSegments[firstSegmentAtCursorIndex];
|
|
invariant(segment != null);
|
|
|
|
const getNewName = (oldName: string, suffix: string) => oldName && `${segment.name} ${suffix}`;
|
|
|
|
if (segment.start === relevantTime || segment.end === relevantTime) return; // No point
|
|
|
|
const firstPart = createIndexedSegment({ segment: { name: getNewName(segment.name, '1'), start: segment.start, end: relevantTime }, incrementCount: false });
|
|
const secondPart = createIndexedSegment({ segment: { name: getNewName(segment.name, '2'), start: relevantTime, end: segment.end }, incrementCount: true });
|
|
|
|
const newSegments = [...cutSegments];
|
|
newSegments.splice(firstSegmentAtCursorIndex, 1, firstPart, secondPart);
|
|
safeSetCutSegments(newSegments);
|
|
}, [createIndexedSegment, cutSegments, findSegmentsAtCursor, getRelevantTime, safeSetCutSegments]);
|
|
|
|
const createNumSegments = useCallback(async () => {
|
|
if (!checkFileOpened() || currentCutSegOrWholeTimeline.duration <= 0) return;
|
|
const segments = await createNumSegmentsDialog(currentCutSegOrWholeTimeline.duration);
|
|
if (!segments) return;
|
|
deleteCurrentCutSeg();
|
|
loadCutSegments({ segments: offsetSegments(segments, currentCutSegOrWholeTimeline.start), append: true, getNextCurrentSegIndex: (edl) => edl.length - 1, clampDuration: fileDuration });
|
|
}, [checkFileOpened, currentCutSegOrWholeTimeline.duration, currentCutSegOrWholeTimeline.start, deleteCurrentCutSeg, fileDuration, loadCutSegments]);
|
|
|
|
const createFixedDurationSegments = useCallback(async () => {
|
|
if (!checkFileOpened() || currentCutSegOrWholeTimeline.duration <= 0) return;
|
|
const segmentDuration = await askForSegmentDuration({ totalDuration: currentCutSegOrWholeTimeline.duration, inputPlaceholder: timecodePlaceholder, parseTimecode });
|
|
if (segmentDuration == null) return;
|
|
deleteCurrentCutSeg();
|
|
const segments = makeDurationSegments(segmentDuration, currentCutSegOrWholeTimeline.duration);
|
|
loadCutSegments({ segments: offsetSegments(segments, currentCutSegOrWholeTimeline.start), append: true, getNextCurrentSegIndex: (edl) => edl.length - 1, clampDuration: fileDuration });
|
|
}, [checkFileOpened, currentCutSegOrWholeTimeline.duration, currentCutSegOrWholeTimeline.start, deleteCurrentCutSeg, fileDuration, loadCutSegments, parseTimecode, timecodePlaceholder]);
|
|
|
|
const createFixedByteSizedSegments = useCallback(async () => {
|
|
if (!checkFileOpened() || !isDurationValid(fileDuration)) return;
|
|
invariant(mainFileMeta != null);
|
|
const fileSize = getFileSize(mainFileMeta.format);
|
|
invariant(fileSize != null);
|
|
const segmentDuration = await createFixedByteSixedSegmentsDialog({ fileDuration, fileSize });
|
|
if (segmentDuration == null) return;
|
|
loadCutSegments({ segments: makeDurationSegments(segmentDuration, fileDuration), append: true, clampDuration: fileDuration });
|
|
}, [checkFileOpened, fileDuration, loadCutSegments, mainFileMeta]);
|
|
|
|
const getSegEstimatedSize = useCallback((segment: Pick<StateSegment, 'start' | 'end'>) => {
|
|
if (mainFileMeta == null || !isDurationValid(fileDuration) || segment.end == null) return undefined;
|
|
const fileSize = getFileSize(mainFileMeta.format);
|
|
if (fileSize == null) return undefined;
|
|
const segDuration = segment.end - segment.start;
|
|
return Math.round((segDuration / fileDuration) * fileSize);
|
|
}, [fileDuration, mainFileMeta]);
|
|
|
|
const createRandomSegments = useCallback(async () => {
|
|
if (!checkFileOpened() || currentCutSegOrWholeTimeline.duration <= 0) return;
|
|
const segments = await createRandomSegmentsDialog(currentCutSegOrWholeTimeline.duration);
|
|
if (!segments) return;
|
|
deleteCurrentCutSeg();
|
|
loadCutSegments({ segments: offsetSegments(segments, currentCutSegOrWholeTimeline.start), append: true, getNextCurrentSegIndex: (edl) => edl.length - 1, clampDuration: fileDuration });
|
|
}, [checkFileOpened, currentCutSegOrWholeTimeline.duration, currentCutSegOrWholeTimeline.start, deleteCurrentCutSeg, fileDuration, loadCutSegments]);
|
|
|
|
const selectSegmentsByLabel = useCallback(async () => {
|
|
const value = await selectSegmentsByLabelDialog(currentCutSeg?.name);
|
|
if (value == null) return;
|
|
selectSegments(cutSegments.filter((seg) => seg.name === value));
|
|
}, [currentCutSeg?.name, cutSegments, selectSegments]);
|
|
|
|
const selectAllMarkers = useCallback(() => {
|
|
selectSegments(cutSegments.filter((seg) => seg.end == null));
|
|
}, [cutSegments, selectSegments]);
|
|
|
|
const selectSegmentsByExpr = useCallback(async () => {
|
|
const matchSegment = async (seg: StateSegment, index: number, expr: string) => (
|
|
(await safeishEval(expr, { segment: getScopeSegment(seg, index) })) === true
|
|
);
|
|
|
|
const getSegmentsToSelect = async (expr: string) => (
|
|
await pMap(cutSegments, async (seg, index) => (
|
|
((await matchSegment(seg, index, expr)) ? [seg] : [])
|
|
), { concurrency: 5 })).flat();
|
|
|
|
const onSubmit = async (value: string) => {
|
|
try {
|
|
if (value.trim().length === 0) return { error: i18n.t('Please enter a JavaScript expression.') };
|
|
const segmentsToSelect = await getSegmentsToSelect(value);
|
|
if (segmentsToSelect.length === 0) return { error: i18n.t('No segments match this expression.') };
|
|
if (segmentsToSelect.length === cutSegments.length) return { error: i18n.t('All segments match this expression.') };
|
|
selectSegments(segmentsToSelect);
|
|
return undefined;
|
|
} catch (err) {
|
|
if (err instanceof Error) {
|
|
return { error: i18n.t('Expression failed: {{errorMessage}}', { errorMessage: err.message }) };
|
|
}
|
|
throw err;
|
|
}
|
|
};
|
|
|
|
showGenericDialog({
|
|
isAlert: true,
|
|
render: () => (
|
|
<ExpressionDialog
|
|
onSubmit={onSubmit}
|
|
confirmButtonText={t('Select segments')}
|
|
examples={[
|
|
{ name: i18n.t('Segment duration less than 5 seconds'), code: 'segment.duration < 5' },
|
|
{ name: i18n.t('Segment starts after 01:00'), code: 'segment.start > 60' },
|
|
{ name: i18n.t('Segment label (exact)'), code: "segment.label === 'My label'" },
|
|
{ name: i18n.t('Segment label (starts with)'), code: "segment.label.startsWith('My lab')" },
|
|
{ name: i18n.t('Segment label (regexp)'), code: '/^My label/.test(segment.label)' },
|
|
{ name: i18n.t('Segment tag value'), code: "segment.tags.myTag === 'tag value'" },
|
|
{ name: i18n.t('Markers'), code: 'segment.end == null' },
|
|
]}
|
|
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 "true" will be selected. <button type="button" className="link-button" onClick={() => shell.openExternal(selectSegmentByExpressionHelpUrl)}>View available syntax.</button></Trans>}
|
|
variables={['segment.index', 'segment.label', 'segment.start', 'segment.end', 'segment.duration', 'segment.tags.*']}
|
|
/>
|
|
),
|
|
});
|
|
}, [showGenericDialog, t, getScopeSegment, cutSegments, selectSegments]);
|
|
|
|
const mutateSegmentsByExpr = 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 UserFacingError(i18n.t('"{{property}}" must be a string', { property: 'label' }));
|
|
ret.name = response.label;
|
|
}
|
|
if ('start' in response) {
|
|
if (typeof response.start !== 'number') throw new UserFacingError(i18n.t('"{{property}}" must be a number', { property: 'start' }));
|
|
ret.start = response.start;
|
|
}
|
|
if ('end' in response) {
|
|
if (!(typeof response.end === 'number' || response.end === undefined)) throw new UserFacingError(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 UserFacingError(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,
|
|
...(seg.selected && await mutateSegment(seg, index, expr)),
|
|
}), { concurrency: 5 })).flat();
|
|
|
|
const onSubmit = async (value: string) => {
|
|
try {
|
|
if (value.trim().length === 0) return { error: i18n.t('Please enter a JavaScript expression.') };
|
|
const mutated = await mutateSegments(value);
|
|
|
|
safeSetCutSegments(mutated, fileDuration);
|
|
|
|
return undefined;
|
|
} catch (err) {
|
|
if (err instanceof Error) {
|
|
return { error: i18n.t('Expression failed: {{errorMessage}}', { errorMessage: err.message }) };
|
|
}
|
|
throw err;
|
|
}
|
|
};
|
|
|
|
showGenericDialog({
|
|
isAlert: true,
|
|
render: () => (
|
|
<ExpressionDialog
|
|
onSubmit={onSubmit}
|
|
confirmButtonText={t('Apply change')}
|
|
examples={[
|
|
{ name: i18n.t('Expand segments +5 sec'), code: '{ start: segment.start - 5, end: segment.end + 5 }' },
|
|
{ name: i18n.t('Shrink segments -5 sec'), code: '{ start: segment.start + 5, end: segment.end - 5 }' },
|
|
{ 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
|
|
{ name: i18n.t('Add number suffix to label'), code: '{ label: `${segment.label} ${segment.index + 1}` }' },
|
|
{ name: i18n.t('Add a tag to every even segment'), code: '{ tags: (segment.index + 1) % 2 === 0 ? { ...segment.tags, even: \'true\' } : segment.tags }' },
|
|
{ name: i18n.t('Convert segments to markers'), code: '{ end: undefined }' },
|
|
{ name: i18n.t('Convert markers to segments'), code: '{ ...(segment.end == null && { end: segment.start + 5 }) }' },
|
|
]}
|
|
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(editSegmentByExpressionHelpUrl)}>View available syntax.</button></Trans>}
|
|
variables={['segment.index', 'segment.label', 'segment.start', 'segment.end', 'segment.tags.*']}
|
|
/>
|
|
),
|
|
});
|
|
}, [cutSegments, fileDuration, getScopeSegment, safeSetCutSegments, showGenericDialog, t]);
|
|
|
|
const labelSelectedSegments = useCallback(async () => {
|
|
const firstSelectedSegment = selectedSegments[0];
|
|
if (firstSelectedSegment == null) return;
|
|
const { name } = firstSelectedSegment;
|
|
const value = await labelSegmentDialog({ currentName: name, maxLength: maxLabelLength });
|
|
if (value == null) return;
|
|
safeSetCutSegments((existingSegments) => existingSegments.map((existingSegment) => {
|
|
if (!existingSegment.selected) return existingSegment;
|
|
return { ...existingSegment, name: value };
|
|
}));
|
|
}, [maxLabelLength, selectedSegments, safeSetCutSegments]);
|
|
|
|
const maybeCreateFullLengthSegment = useCallback((newFileDuration: number) => {
|
|
// don't use safeSetCutSegments because we want to set initial: true
|
|
setCutSegments((existing) => {
|
|
if (existing.length > 0 || newFileDuration <= 0) return existing;
|
|
const segment = { start: 0, end: newFileDuration, initial: true } as const;
|
|
console.log('Creating initial segment', segment);
|
|
return [createIndexedSegment({ segment })];
|
|
});
|
|
}, [createIndexedSegment, setCutSegments]);
|
|
|
|
const segmentsOrInverse = useMemo<{ selected: SegmentToExport[], all: DefiniteSegmentBase[] }>(() => {
|
|
// For invertCutSegments we do not support filtering (selecting) segments
|
|
if (invertCutSegments) {
|
|
return {
|
|
selected: inverseCutSegments.map((seg, i) => ({ ...seg, originalIndex: i })),
|
|
all: inverseCutSegments,
|
|
};
|
|
}
|
|
|
|
const nonMarkers = filterNonMarkers(selectedSegments);
|
|
|
|
// If user has selected no segments, default to all instead.
|
|
const selectedSegmentsWithFallback = nonMarkers.length > 0 ? nonMarkers : filterNonMarkers(cutSegments).map((seg, i) => ({ ...seg, originalIndex: i }));
|
|
|
|
return {
|
|
// exclude markers (segments without any end)
|
|
// and exclude the initial segment, to prevent cutting when not really needed (if duration changes after the segment was created)
|
|
selected: selectedSegmentsWithFallback.filter((seg) => !seg.initial),
|
|
|
|
// `all` includes also all non selected segments:
|
|
all: filterNonMarkers(cutSegments).filter((seg) => !seg.initial),
|
|
};
|
|
}, [cutSegments, inverseCutSegments, invertCutSegments, selectedSegments]);
|
|
|
|
const segmentsToExport = useMemo<SegmentToExport[]>(() => {
|
|
// 'segmentsToChaptersOnly' is a special mode where all segments will be simply written out as chapters to one file: https://github.com/mifi/lossless-cut/issues/993#issuecomment-1037927595
|
|
// Chapters export mode: Emulate no cuts (full timeline)
|
|
if (segmentsToChaptersOnly) return [];
|
|
// in other modes, return all selected segments
|
|
return segmentsOrInverse.selected;
|
|
}, [segmentsOrInverse.selected, segmentsToChaptersOnly]);
|
|
|
|
const removeSelectedSegments = useCallback(() => removeSegments(selectedSegments.map((seg) => seg.segId)), [removeSegments, selectedSegments]);
|
|
|
|
const selectOnlySegment = useCallback((seg: Pick<StateSegment, 'segId'>) => setCutSegments((existing) => existing.map((segment) => ({
|
|
...segment, selected: segment.segId === seg.segId,
|
|
}))), [setCutSegments]);
|
|
|
|
const toggleSegmentSelected = useCallback((seg: Pick<StateSegment, 'segId'>) => setCutSegments((existing) => existing.map((segment) => {
|
|
if (segment.segId !== seg.segId) return segment;
|
|
return { ...segment, selected: !segment.selected };
|
|
})), [setCutSegments]);
|
|
|
|
const deselectAllSegments = useCallback(() => setCutSegments((existing) => existing.map((segment) => ({ ...segment, selected: false }))), [setCutSegments]);
|
|
const selectAllSegments = useCallback(() => setCutSegments((existing) => existing.map((segment) => ({ ...segment, selected: true }))), [setCutSegments]);
|
|
const invertSelectedSegments = useCallback(() => setCutSegments((existing) => existing.map((segment) => ({ ...segment, selected: !segment.selected }))), [setCutSegments]);
|
|
|
|
const selectOnlyCurrentSegment = useCallback(() => {
|
|
if (currentCutSeg == null) return;
|
|
selectOnlySegment(currentCutSeg);
|
|
}, [currentCutSeg, selectOnlySegment]);
|
|
|
|
const toggleCurrentSegmentSelected = useCallback(() => {
|
|
if (currentCutSeg == null) return;
|
|
toggleSegmentSelected(currentCutSeg);
|
|
}, [currentCutSeg, toggleSegmentSelected]);
|
|
|
|
return {
|
|
cutSegments,
|
|
cutSegmentsHistory,
|
|
createSegmentsFromKeyframes,
|
|
shuffleSegments,
|
|
detectBlackScenes,
|
|
detectSilentScenes,
|
|
detectSceneChanges,
|
|
removeSegment,
|
|
invertAllSegments,
|
|
fillSegmentsGaps,
|
|
combineOverlappingSegments,
|
|
combineSelectedSegments,
|
|
shiftAllSegmentTimes,
|
|
alignSegmentTimesToKeyframes,
|
|
updateSegOrder,
|
|
updateSegOrders,
|
|
reorderSegsByStartTime,
|
|
addSegment,
|
|
duplicateCurrentSegment,
|
|
duplicateSegment,
|
|
setCutStart,
|
|
setCutEnd,
|
|
labelSegment,
|
|
splitCurrentSegment,
|
|
focusSegmentAtCursor,
|
|
selectSegmentsAtCursor,
|
|
createNumSegments,
|
|
createFixedDurationSegments,
|
|
createFixedByteSizedSegments,
|
|
createRandomSegments,
|
|
getSegEstimatedSize,
|
|
haveInvalidSegs,
|
|
currentSegIndexSafe,
|
|
currentCutSeg,
|
|
inverseCutSegments,
|
|
clearSegments,
|
|
clearSegColorCounter,
|
|
loadCutSegments,
|
|
selectedSegments,
|
|
segmentsOrInverse,
|
|
segmentsToExport,
|
|
maybeCreateFullLengthSegment,
|
|
|
|
setCurrentSegIndex,
|
|
|
|
labelSelectedSegments,
|
|
deselectAllSegments,
|
|
selectAllSegments,
|
|
selectOnlyCurrentSegment,
|
|
toggleCurrentSegmentSelected,
|
|
invertSelectedSegments,
|
|
removeSelectedSegments,
|
|
selectSegmentsByLabel,
|
|
selectSegmentsByExpr,
|
|
selectAllMarkers,
|
|
mutateSegmentsByExpr,
|
|
toggleSegmentSelected,
|
|
selectOnlySegment,
|
|
setCutTime,
|
|
updateSegAtIndex,
|
|
findSegmentsAtCursor,
|
|
currentCutSegOrWholeTimeline,
|
|
segColorCounter,
|
|
};
|
|
}
|
|
|
|
export type UseSegments = ReturnType<typeof useSegments>;
|
|
|
|
export default useSegments;
|