From a5b02fadf0dcb14c8e231f73126bc32aa4b2262d Mon Sep 17 00:00:00 2001 From: Mikael Finstad Date: Sun, 5 Feb 2023 21:11:24 +0800 Subject: [PATCH] refactor: move out segments functionality --- src/App.jsx | 451 ++--------------------------------- src/hooks/useSegments.js | 490 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 510 insertions(+), 431 deletions(-) create mode 100644 src/hooks/useSegments.js diff --git a/src/App.jsx b/src/App.jsx index bbfcbc34..b235d923 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -3,13 +3,10 @@ import { FaAngleLeft, FaWindowClose } from 'react-icons/fa'; import { MdRotate90DegreesCcw } from 'react-icons/md'; import { AnimatePresence, motion } from 'framer-motion'; import { Heading, InlineAlert, Table, SideSheet, Position, ThemeProvider } from 'evergreen-ui'; -import { useStateWithHistory } from 'react-use/lib/useStateWithHistory'; import useDebounceOld from 'react-use/lib/useDebounce'; // Want to phase out this import { useDebounce } from 'use-debounce'; import i18n from 'i18next'; import { useTranslation } from 'react-i18next'; -import JSON5 from 'json5'; -import pMap from 'p-map'; import fromPairs from 'lodash/fromPairs'; import sortBy from 'lodash/sortBy'; @@ -26,6 +23,7 @@ import useWaveform from './hooks/useWaveform'; import useKeyboard from './hooks/useKeyboard'; import useFileFormatState from './hooks/useFileFormatState'; import useFrameCapture from './hooks/useFrameCapture'; +import useSegments from './hooks/useSegments'; import UserSettingsContext from './contexts/UserSettingsContext'; @@ -55,9 +53,9 @@ import { getStreamFps, isCuttingStart, isCuttingEnd, readFileMeta, getSmarterOutFormat, renderThumbnails as ffmpegRenderThumbnails, extractStreams, setCustomFfPath as ffmpegSetCustomFfPath, - isIphoneHevc, isProblematicAvc1, tryMapChaptersToEdl, blackDetect, silenceDetect, detectSceneChanges as ffmpegDetectSceneChanges, + isIphoneHevc, isProblematicAvc1, tryMapChaptersToEdl, getDuration, getTimecodeFromStreams, createChaptersFromSegments, extractSubtitleTrack, - RefuseOverwriteError, readFrames, mapTimesToSegments, abortFfmpegs, findKeyframeNearTime, + RefuseOverwriteError, abortFfmpegs, } from './ffmpeg'; import { shouldCopyStreamByDefault, getAudioStreams, getRealVideoStreams, isAudioDefinitelyNotSupported, willPlayerProperlyHandleVideo, doesPlayerSupportHevcPlayback, isStreamThumbnail } from './util/streams'; import { exportEdlFile, readEdlFile, saveLlcProject, loadLlcProject, askForEdlImport } from './edlStore'; @@ -67,20 +65,18 @@ import { checkDirWriteAccess, dirExists, isMasBuild, isStoreBuild, dragPreventer, filenamify, getOutFileExtension, generateSegFileName, defaultOutSegTemplate, havePermissionToReadFile, resolvePathIfNeeded, getPathReadAccessError, html5ifiedPrefix, html5dummySuffix, findExistingHtml5FriendlyFile, - deleteFiles, isOutOfSpaceError, shuffleArray, getNumDigits, isExecaFailure, readFileSize, readFileSizes, checkFileSizes, + deleteFiles, isOutOfSpaceError, getNumDigits, isExecaFailure, readFileSize, readFileSizes, checkFileSizes, } from './util'; import { formatDuration } from './util/duration'; import { adjustRate } from './util/rate-calculator'; -import { showParametersDialog } from './dialogs/parameters'; import { askExtractFramesAsImages } from './dialogs/extractFrames'; import { askForHtml5ifySpeed } from './dialogs/html5ify'; -import { askForOutDir, askForInputDir, askForImportChapters, createNumSegments as createNumSegmentsDialog, createFixedDurationSegments as createFixedDurationSegmentsDialog, createRandomSegments as createRandomSegmentsDialog, promptTimeOffset, askForFileOpenAction, confirmExtractAllStreamsDialog, showCleanupFilesDialog, showDiskFull, showExportFailedDialog, showConcatFailedDialog, labelSegmentDialog, openYouTubeChaptersDialog, openAbout, showEditableJsonDialog, askForShiftSegments, selectSegmentsByLabelDialog, showRefuseToOverwrite, openDirToast, openCutFinishedToast, openConcatFinishedToast, askForAlignSegments } from './dialogs'; +import { askForOutDir, askForInputDir, askForImportChapters, promptTimeOffset, askForFileOpenAction, confirmExtractAllStreamsDialog, showCleanupFilesDialog, showDiskFull, showExportFailedDialog, showConcatFailedDialog, openYouTubeChaptersDialog, openAbout, showRefuseToOverwrite, openDirToast, openCutFinishedToast, openConcatFinishedToast } from './dialogs'; import { openSendReportDialog } from './reporting'; import { fallbackLng } from './i18n'; -import { createSegment, getCleanCutSegments, getSegApparentStart, getSegApparentEnd as getSegApparentEnd2, findSegmentsAtCursor, sortSegments, invertSegments, getSegmentTags, convertSegmentsToChapters, hasAnySegmentOverlap, combineOverlappingSegments as combineOverlappingSegments2, isDurationValid } from './segments'; +import { createSegment, getCleanCutSegments, findSegmentsAtCursor, sortSegments, getSegmentTags, convertSegmentsToChapters, hasAnySegmentOverlap, isDurationValid } from './segments'; import { getOutSegError as getOutSegErrorRaw } from './util/outputNameTemplate'; -import * as ffmpegParameters from './ffmpeg-parameters'; -import { maxSegmentsAllowed, ffmpegExtractWindow, zoomMax, rightBarWidth, leftBarWidth } from './util/constants'; +import { rightBarWidth, leftBarWidth, ffmpegExtractWindow, zoomMax } from './util/constants'; import isDev from './isDev'; @@ -142,7 +138,6 @@ const App = memo(() => { const [thumbnails, setThumbnails] = useState([]); const [shortestFlag, setShortestFlag] = useState(false); const [zoomWindowStartTime, setZoomWindowStartTime] = useState(0); - const [deselectedSegmentIds, setDeselectedSegmentIds] = useState({}); const [subtitlesByStreamId, setSubtitlesByStreamId] = useState({}); const [activeSubtitleStreamIndex, setActiveSubtitleStreamIndex] = useState(); const [hideCanvasPreview, setHideCanvasPreview] = useState(false); @@ -167,34 +162,8 @@ const App = memo(() => { const [batchFiles, setBatchFiles] = useState([]); const [selectedBatchFiles, setSelectedBatchFiles] = useState([]); - // Segment related state - const segCounterRef = useRef(0); - const clearSegCounter = useCallback(() => { - segCounterRef.current = 0; - }, []); - - const createIndexedSegment = useCallback(({ segment, incrementCount } = {}) => { - if (incrementCount) segCounterRef.current += 1; - const ret = createSegment({ segColorIndex: segCounterRef.current, ...segment }); - return ret; - }, []); - - const createInitialCutSegments = useCallback(() => [createIndexedSegment()], [createIndexedSegment]); - - const [currentSegIndex, setCurrentSegIndex] = useState(0); const [cutStartTimeManual, setCutStartTimeManual] = useState(); const [cutEndTimeManual, setCutEndTimeManual] = useState(); - const [cutSegments, setCutSegments, cutSegmentsHistory] = useStateWithHistory( - createInitialCutSegments(), - 100, - ); - - const clearSegments = useCallback(() => { - clearSegCounter(); - setCutSegments(createInitialCutSegments()); - }, [clearSegCounter, createInitialCutSegments, setCutSegments]); - - const shuffleSegments = useCallback(() => setCutSegments((oldSegments) => shuffleArray(oldSegments)), [setCutSegments]); // Store "working" in a ref so we can avoid race conditions const workingRef = useRef(working); @@ -344,7 +313,7 @@ const App = memo(() => { // 360 means we don't modify rotation const isRotationSet = rotation !== 360; - const effectiveRotation = isRotationSet ? rotation : (mainVideoStream && mainVideoStream.tags && mainVideoStream.tags.rotate && parseInt(mainVideoStream.tags.rotate, 10)); + const effectiveRotation = useMemo(() => (isRotationSet ? rotation : (mainVideoStream && mainVideoStream.tags && mainVideoStream.tags.rotate && parseInt(mainVideoStream.tags.rotate, 10))), [isRotationSet, mainVideoStream, rotation]); const zoomRel = useCallback((rel) => setZoom((z) => Math.min(Math.max(z + (rel * (1 + (z / 10))), 1), zoomMax)), []); const canvasPlayerRequired = !!(mainVideoStream && usingDummyVideo); @@ -369,22 +338,19 @@ const App = memo(() => { const onTimelineWheel = useTimelineScroll({ wheelSensitivity, mouseWheelZoomModifierKey, invertTimelineScroll, zoomRel, seekRel }); - const getSegApparentEnd = useCallback((seg) => getSegApparentEnd2(seg, duration), [duration]); + const getCurrentTime = useCallback(() => (playing ? videoRef.current.currentTime : commandedTimeRef.current), [playing]); - const getApparentCutSegments = useCallback((segments) => segments.map((cutSegment) => ({ - ...cutSegment, - start: getSegApparentStart(cutSegment), - end: getSegApparentEnd(cutSegment), - })), [getSegApparentEnd]); + const maxLabelLength = safeOutputFileName ? 100 : 500; - // These are segments guaranteed to have a start and end time - const apparentCutSegments = useMemo(() => getApparentCutSegments(cutSegments), [cutSegments, getApparentCutSegments]); + const checkFileOpened = useCallback(() => { + if (isFileOpened) return true; + toast.fire({ icon: 'info', title: i18n.t('You need to open a media file first') }); + return false; + }, [isFileOpened]); - const haveInvalidSegs = useMemo(() => apparentCutSegments.some((cutSegment) => cutSegment.start >= cutSegment.end), [apparentCutSegments]); - - const currentSegIndexSafe = Math.min(currentSegIndex, cutSegments.length - 1); - const currentCutSeg = useMemo(() => cutSegments[currentSegIndexSafe], [currentSegIndexSafe, cutSegments]); - const currentApparentCutSeg = useMemo(() => apparentCutSegments[currentSegIndexSafe], [apparentCutSegments, currentSegIndexSafe]); + const { + cutSegments, cutSegmentsHistory, createSegmentsFromKeyframes, shuffleSegments, detectBlackScenes, detectSilentScenes, detectSceneChanges, removeCutSegment, invertAllSegments, fillSegmentsGaps, combineOverlappingSegments, shiftAllSegmentTimes, alignSegmentTimesToKeyframes, onViewSegmentTags, updateSegOrder, updateSegOrders, reorderSegsByStartTime, addSegment, setCutStart, setCutEnd, onLabelSegment, splitCurrentSegment, createNumSegments, createFixedDurationSegments, createRandomSegments, apparentCutSegments, haveInvalidSegs, currentSegIndexSafe, currentCutSeg, currentApparentCutSeg, inverseCutSegments, clearSegments, loadCutSegments, selectedSegmentsRaw, setCutTime, getSegApparentEnd, setCurrentSegIndex, onLabelSelectedSegments, deselectAllSegments, selectAllSegments, selectOnlyCurrentSegment, toggleCurrentSegmentSelected, removeSelectedSegments, setDeselectedSegmentIds, onSelectSegmentsByLabel, toggleSegmentSelected, selectOnlySegment, + } = useSegments({ filePath, workingRef, setWorking, setCutProgress, mainVideoStream, duration, getCurrentTime, maxLabelLength, checkFileOpened }); const jumpSegStart = useCallback((index) => seekAbs(apparentCutSegments[index].start), [apparentCutSegments, seekAbs]); const jumpSegEnd = useCallback((index) => seekAbs(apparentCutSegments[index].end), [apparentCutSegments, seekAbs]); @@ -393,162 +359,6 @@ const App = memo(() => { const jumpTimelineStart = useCallback(() => seekAbs(0), [seekAbs]); const jumpTimelineEnd = useCallback(() => seekAbs(durationSafe), [durationSafe, seekAbs]); - const inverseCutSegments = useMemo(() => { - const inverted = !haveInvalidSegs && isDurationValid(duration) ? invertSegments(sortSegments(apparentCutSegments), true, true, duration) : undefined; - return (inverted || []).map((seg) => ({ ...seg, segId: `${seg.start}-${seg.end}` })); - }, [apparentCutSegments, duration, haveInvalidSegs]); - - const invertAllSegments = useCallback(() => { - if (inverseCutSegments.length < 1) { - errorToast(i18n.t('Make sure you have no overlapping segments.')); - return; - } - // don't reset segColorIndex (which represent colors) when inverting - const newInverseCutSegments = inverseCutSegments.map((inverseSegment, index) => createSegment({ ...inverseSegment, segColorIndex: index })); - setCutSegments(newInverseCutSegments); - }, [inverseCutSegments, setCutSegments]); - - const fillSegmentsGaps = useCallback(() => { - if (inverseCutSegments.length < 1) { - errorToast(i18n.t('Make sure you have no overlapping segments.')); - return; - } - const newInverseCutSegments = inverseCutSegments.map((inverseSegment) => createIndexedSegment({ segment: inverseSegment, incrementCount: true })); - setCutSegments((existing) => ([...existing, ...newInverseCutSegments])); - }, [createIndexedSegment, inverseCutSegments, setCutSegments]); - - const combineOverlappingSegments = useCallback(() => { - setCutSegments((existingSegments) => combineOverlappingSegments2(existingSegments, getSegApparentEnd)); - }, [getSegApparentEnd, setCutSegments]); - - const updateSegAtIndex = useCallback((index, newProps) => { - if (index < 0) return; - const cutSegmentsNew = [...cutSegments]; - cutSegmentsNew.splice(index, 1, { ...cutSegments[index], ...newProps }); - setCutSegments(cutSegmentsNew); - }, [setCutSegments, cutSegments]); - - const setCutTime = useCallback((type, time) => { - if (!isDurationValid(duration)) return; - - const currentSeg = currentCutSeg; - if (type === 'start' && time >= getSegApparentEnd(currentSeg)) { - throw new Error('Start time must precede end time'); - } - if (type === 'end' && time <= getSegApparentStart(currentSeg)) { - throw new Error('Start time must precede end time'); - } - updateSegAtIndex(currentSegIndexSafe, { [type]: Math.min(Math.max(time, 0), duration) }); - }, [currentSegIndexSafe, getSegApparentEnd, currentCutSeg, duration, updateSegAtIndex]); - - const isSegmentSelected = useCallback(({ segId }) => !deselectedSegmentIds[segId], [deselectedSegmentIds]); - - const modifySelectedSegmentTimes = useCallback(async (transformSegment, concurrency = 5) => { - const clampValue = (val) => Math.min(Math.max(val, 0), duration); - - let 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; - }, { concurrency }); - newSegments = newSegments.filter((segment) => segment.end > segment.start); - if (newSegments.length < 1) setCutSegments(createInitialCutSegments()); - else setCutSegments(newSegments); - }, [apparentCutSegments, createInitialCutSegments, duration, isSegmentSelected, setCutSegments]); - - const shiftAllSegmentTimes = useCallback(async () => { - const shift = await askForShiftSegments(); - if (shift == null) return; - - const { shiftAmount, shiftKeys } = shift; - await modifySelectedSegmentTimes((segment) => { - const newSegment = { ...segment }; - shiftKeys.forEach((key) => { - newSegment[key] += shiftAmount; - }); - return newSegment; - }); - }, [modifySelectedSegmentTimes]); - - const alignSegmentTimesToKeyframes = useCallback(async () => { - if (!mainVideoStream || workingRef.current) return; - try { - const response = await askForAlignSegments(); - if (response == null) return; - setWorking(i18n.t('Aligning segments to keyframes')); - const { mode, startOrEnd } = response; - await modifySelectedSegmentTimes(async (segment) => { - const newSegment = { ...segment }; - - async function align(key) { - const time = newSegment[key]; - const keyframe = await findKeyframeNearTime({ filePath, streamIndex: mainVideoStream.index, time, mode }); - if (!keyframe == null) throw new Error(`Cannot find any keyframe within 60 seconds of frame ${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(); - } - }, [filePath, mainVideoStream, modifySelectedSegmentTimes, setWorking]); - - const maxLabelLength = safeOutputFileName ? 100 : 500; - - const onSelectSegmentsByLabel = useCallback(async () => { - const { name } = currentCutSeg; - const value = await selectSegmentsByLabelDialog(name); - if (value == null) return; - const segmentsToEnable = cutSegments.filter((seg) => (seg.name || '') === value); - if (segmentsToEnable.length === 0 || segmentsToEnable.length === cutSegments.length) return; // no point - setDeselectedSegmentIds((existing) => { - const ret = { ...existing }; - segmentsToEnable.forEach(({ segId }) => { ret[segId] = false; }); - return ret; - }); - }, [currentCutSeg, cutSegments]); - - const onViewSegmentTags = useCallback(async (index) => { - const segment = cutSegments[index]; - function inputValidator(jsonStr) { - try { - const json = JSON5.parse(jsonStr); - if (!(typeof json === 'object' && Object.values(json).every((val) => typeof val === 'string'))) throw new Error(); - return undefined; - } catch (err) { - return i18n.t('Invalid JSON'); - } - } - const tags = getSegmentTags(segment); - const newTagsStr = await showEditableJsonDialog({ title: i18n.t('Segment tags'), text: i18n.t('View and edit segment tags in JSON5 format:'), inputValue: Object.keys(tags).length > 0 ? JSON5.stringify(tags, null, 2) : '', inputValidator }); - if (newTagsStr != null) updateSegAtIndex(index, { tags: JSON5.parse(newTagsStr) }); - }, [cutSegments, updateSegAtIndex]); - - const updateSegOrder = useCallback((index, newOrder) => { - if (newOrder > cutSegments.length - 1 || newOrder < 0) return; - const newSegments = [...cutSegments]; - const removedSeg = newSegments.splice(index, 1)[0]; - newSegments.splice(newOrder, 0, removedSeg); - setCutSegments(newSegments); - setCurrentSegIndex(newOrder); - }, [cutSegments, setCutSegments]); - - const updateSegOrders = useCallback((newOrders) => { - const newSegments = sortBy(cutSegments, (seg) => newOrders.indexOf(seg.segId)); - const newCurrentSegIndex = newOrders.indexOf(currentCutSeg.segId); - setCutSegments(newSegments); - if (newCurrentSegIndex >= 0 && newCurrentSegIndex < newSegments.length) setCurrentSegIndex(newCurrentSegIndex); - }, [cutSegments, setCutSegments, currentCutSeg]); - - const reorderSegsByStartTime = useCallback(() => { - setCutSegments(sortBy(cutSegments, getSegApparentStart)); - }, [cutSegments, setCutSegments]); const getFrameCount = useCallback((sec) => getFrameCountRaw(detectedFps, sec), [detectedFps]); @@ -565,74 +375,8 @@ const App = memo(() => { const { captureFrameFromTag, captureFrameFromFfmpeg, captureFramesRange } = useFrameCapture({ formatTimecode }); - const getCurrentTime = useCallback(() => (playing ? videoRef.current.currentTime : commandedTimeRef.current), [playing]); - // const getSafeCutTime = useCallback((cutTime, next) => ffmpeg.getSafeCutTime(neighbouringFrames, cutTime, next), [neighbouringFrames]); - const addSegment = useCallback(() => { - try { - // Cannot add if prev seg is not finished - if (currentCutSeg.start === undefined && currentCutSeg.end === undefined) return; - - const suggestedStart = getCurrentTime(); - /* if (keyframeCut) { - const keyframeAlignedStart = getSafeCutTime(suggestedStart, true); - if (keyframeAlignedStart != null) suggestedStart = keyframeAlignedStart; - } */ - - if (suggestedStart >= duration) return; - - const cutSegmentsNew = [ - ...cutSegments, - createIndexedSegment({ segment: { start: suggestedStart }, incrementCount: true }), - ]; - - setCutSegments(cutSegmentsNew); - setCurrentSegIndex(cutSegmentsNew.length - 1); - } catch (err) { - console.error(err); - } - }, [currentCutSeg.start, currentCutSeg.end, getCurrentTime, duration, cutSegments, createIndexedSegment, setCutSegments]); - - const setCutStart = useCallback(() => { - if (!filePath) return; - - const currentTime = getCurrentTime(); - // https://github.com/mifi/lossless-cut/issues/168 - // If current time is after the end of the current segment in the timeline, - // add a new segment that starts at playerTime - if (currentCutSeg.end != null && currentTime >= currentCutSeg.end) { - addSegment(); - } else { - try { - const startTime = currentTime; - /* if (keyframeCut) { - const keyframeAlignedCutTo = getSafeCutTime(startTime, true); - if (keyframeAlignedCutTo != null) startTime = keyframeAlignedCutTo; - } */ - setCutTime('start', startTime); - } catch (err) { - handleError(err); - } - } - }, [filePath, getCurrentTime, currentCutSeg.end, addSegment, setCutTime]); - - const setCutEnd = useCallback(() => { - if (!filePath) return; - - try { - const endTime = getCurrentTime(); - - /* if (keyframeCut) { - const keyframeAlignedCutTo = getSafeCutTime(endTime, false); - if (keyframeAlignedCutTo != null) endTime = keyframeAlignedCutTo; - } */ - setCutTime('end', endTime); - } catch (err) { - handleError(err); - } - }, [filePath, getCurrentTime, setCutTime]); - const outputDir = getOutDir(customOutDir, filePath); const changeOutDir = useCallback(async () => { @@ -886,22 +630,6 @@ const App = memo(() => { }); }, [copyAnyAudioTrack, filePath, mainStreams, setCopyStreamIdsForPath]); - const removeSegments = useCallback((removeSegmentIds) => { - if (cutSegments.length === 1 && cutSegments[0].start == null && cutSegments[0].end == null) return; // We are at initial segment, nothing more we can do (it cannot be removed) - setCutSegments((existing) => { - const newSegments = existing.filter((seg) => !removeSegmentIds.includes(seg.segId)); - if (newSegments.length === 0) { - clearSegments(); // when removing the last segments, we start over - return existing; - } - return newSegments; - }); - }, [clearSegments, cutSegments, setCutSegments]); - - const removeCutSegment = useCallback((index) => { - removeSegments([cutSegments[index].segId]); - }, [cutSegments, removeSegments]); - const thumnailsRef = useRef([]); const thumnailsRenderingPromiseRef = useRef(); @@ -1000,7 +728,7 @@ const App = memo(() => { setExportConfirmVisible(false); cancelRenderThumbnails(); - }, [cutSegmentsHistory, clearSegments, setFileFormat, setDetectedFileFormat, cancelRenderThumbnails]); + }, [cutSegmentsHistory, clearSegments, setFileFormat, setDetectedFileFormat, setDeselectedSegmentIds, cancelRenderThumbnails]); const showUnsupportedFileMessage = useCallback(() => { @@ -1298,8 +1026,6 @@ const App = memo(() => { } }, [isFileOpened, cleanupChoices, cleanupFiles, setWorking]); - const selectedSegmentsRaw = useMemo(() => apparentCutSegments.filter(isSegmentSelected), [apparentCutSegments, isSegmentSelected]); - // For invertCutSegments we do not support filtering const selectedSegmentsOrInverseRaw = useMemo(() => (invertCutSegments ? inverseCutSegments : selectedSegmentsRaw), [inverseCutSegments, invertCutSegments, selectedSegmentsRaw]); @@ -1308,32 +1034,6 @@ const App = memo(() => { // If user has selected none to export, it makes no sense, so export all instead const selectedSegmentsOrInverse = selectedSegmentsOrInverseRaw.length > 0 ? selectedSegmentsOrInverseRaw : nonFilteredSegments; - const selectOnlySegment = useCallback((seg) => setDeselectedSegmentIds(Object.fromEntries(cutSegments.filter((s) => s.segId !== seg.segId).map((s) => [s.segId, true]))), [cutSegments]); - const toggleSegmentSelected = useCallback((seg) => setDeselectedSegmentIds((existing) => ({ ...existing, [seg.segId]: !existing[seg.segId] })), []); - const deselectAllSegments = useCallback(() => setDeselectedSegmentIds(Object.fromEntries(cutSegments.map((s) => [s.segId, true]))), [cutSegments]); - const selectAllSegments = useCallback(() => setDeselectedSegmentIds({}), []); - - const removeSelectedSegments = useCallback(() => removeSegments(selectedSegmentsRaw.map((seg) => seg.segId)), [removeSegments, selectedSegmentsRaw]); - - const selectOnlyCurrentSegment = useCallback(() => selectOnlySegment(currentCutSeg), [currentCutSeg, selectOnlySegment]); - const toggleCurrentSegmentSelected = useCallback(() => toggleSegmentSelected(currentCutSeg), [currentCutSeg, toggleSegmentSelected]); - - const onLabelSegment = useCallback(async (index) => { - const { name } = cutSegments[index]; - const value = await labelSegmentDialog({ currentName: name, maxLength: maxLabelLength }); - if (value != null) updateSegAtIndex(index, { name: value }); - }, [cutSegments, updateSegAtIndex, maxLabelLength]); - - const onLabelSelectedSegments = useCallback(async () => { - if (selectedSegmentsRaw.length < 1) return; - const { name } = selectedSegmentsRaw[0]; - const value = await labelSegmentDialog({ currentName: name, maxLength: maxLabelLength }); - setCutSegments((existingSegments) => existingSegments.map((existingSegment) => { - if (selectedSegmentsRaw.some((seg) => seg.segId === existingSegment.segId)) return { ...existingSegment, name: value }; - return existingSegment; - })); - }, [maxLabelLength, selectedSegmentsRaw, setCutSegments]); - const segmentsToExport = useMemo(() => { if (!segmentsToChaptersOnly) return selectedSegmentsOrInverse; // 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 @@ -1595,51 +1295,6 @@ const App = memo(() => { return cutSegments[firstSegmentAtCursorIndex]; }, [apparentCutSegments, commandedTime, cutSegments]); - const splitCurrentSegment = useCallback(() => { - const currentTime = getCurrentTime(); - const segmentsAtCursorIndexes = findSegmentsAtCursor(apparentCutSegments, currentTime); - - if (segmentsAtCursorIndexes.length === 0) { - errorToast(i18n.t('No segment to split. Please move cursor over the segment you want to split')); - return; - } - - const firstSegmentAtCursorIndex = segmentsAtCursorIndexes[0]; - const segment = cutSegments[firstSegmentAtCursorIndex]; - - const getNewName = (oldName, suffix) => oldName && `${segment.name} ${suffix}`; - - const firstPart = createIndexedSegment({ segment: { name: getNewName(segment.name, '1'), start: segment.start, end: currentTime }, incrementCount: false }); - const secondPart = createIndexedSegment({ segment: { name: getNewName(segment.name, '2'), start: currentTime, end: segment.end }, incrementCount: true }); - - const newSegments = [...cutSegments]; - newSegments.splice(firstSegmentAtCursorIndex, 1, firstPart, secondPart); - setCutSegments(newSegments); - }, [apparentCutSegments, createIndexedSegment, cutSegments, getCurrentTime, setCutSegments]); - - const loadCutSegments = useCallback((edl, append = false) => { - const validEdl = edl.filter((row) => ( - (row.start === undefined || row.end === undefined || row.start < row.end) - && (row.start === undefined || row.start >= 0) - // TODO: Cannot do this because duration is not yet set when loading a file - // && (row.start === undefined || (row.start >= 0 && row.start < duration)) - // && (row.end === undefined || row.end < duration) - )); - - if (validEdl.length === 0) throw new Error(i18n.t('No valid segments found')); - - if (!append) clearSegCounter(); - - if (validEdl.length > maxSegmentsAllowed) throw new Error(i18n.t('Tried to create too many segments (max {{maxSegmentsAllowed}}.)', { maxSegmentsAllowed })); - - setCutSegments((existingSegments) => { - const needToAppend = append && existingSegments.length > 1; - let newSegments = validEdl.map((segment, i) => createIndexedSegment({ segment, incrementCount: needToAppend || i > 0 })); - if (needToAppend) newSegments = [...existingSegments, ...newSegments]; - return newSegments; - }); - }, [clearSegCounter, createIndexedSegment, setCutSegments]); - const loadEdlFile = useCallback(async ({ path, type, append }) => { console.log('Loading EDL file', type, path, append); loadCutSegments(await readEdlFile({ type, path }), append); @@ -1693,7 +1348,6 @@ const App = memo(() => { } } - try { const fileMeta = await readFileMeta(fp); // console.log('file meta read', fileMeta); @@ -1763,7 +1417,7 @@ const App = memo(() => { const toggleLastCommands = useCallback(() => setLastCommandsVisible(val => !val), []); const toggleSettings = useCallback(() => setSettingsVisible(val => !val), []); - const jumpSeg = useCallback((val) => setCurrentSegIndex((old) => Math.max(Math.min(old + val, cutSegments.length - 1), 0)), [cutSegments.length]); + const jumpSeg = useCallback((val) => setCurrentSegIndex((old) => Math.max(Math.min(old + val, cutSegments.length - 1), 0)), [cutSegments.length, setCurrentSegIndex]); const seekClosestKeyframe = useCallback((direction) => { const time = findNearestKeyFrameTime({ time: getCurrentTime(), direction }); @@ -1892,47 +1546,6 @@ const App = memo(() => { } }, [customOutDir, enableOverwriteOutput, filePath, hideAllNotifications, mainCopiedStreams, setWorking]); - const detectSegments = useCallback(async ({ name, workingText, errorText, fn }) => { - if (!filePath) return; - if (workingRef.current) return; - try { - setWorking(workingText); - setCutProgress(0); - - const newSegments = await fn(); - console.log(name, newSegments); - loadCutSegments(newSegments, true); - } catch (err) { - handleError(errorText, err); - } finally { - setWorking(); - setCutProgress(); - } - }, [filePath, setWorking, loadCutSegments]); - - const detectBlackScenes = useCallback(async () => { - const filterOptions = await showParametersDialog({ title: i18n.t('Enter parameters'), parameters: ffmpegParameters.blackdetect(), docUrl: 'https://ffmpeg.org/ffmpeg-filters.html#blackdetect' }); - if (filterOptions == null) return; - await detectSegments({ name: 'blackScenes', workingText: i18n.t('Detecting black scenes'), errorText: i18n.t('Failed to detect black scenes'), fn: async () => blackDetect({ filePath, filterOptions, onProgress: setCutProgress, from: currentApparentCutSeg.start, to: currentApparentCutSeg.end }) }); - }, [currentApparentCutSeg.end, currentApparentCutSeg.start, detectSegments, filePath]); - - const detectSilentScenes = useCallback(async () => { - const filterOptions = await showParametersDialog({ title: i18n.t('Enter parameters'), parameters: ffmpegParameters.silencedetect(), docUrl: 'https://ffmpeg.org/ffmpeg-filters.html#silencedetect' }); - if (filterOptions == null) return; - await detectSegments({ name: 'silentScenes', workingText: i18n.t('Detecting silent scenes'), errorText: i18n.t('Failed to detect silent scenes'), fn: async () => silenceDetect({ filePath, filterOptions, onProgress: setCutProgress, from: currentApparentCutSeg.start, to: currentApparentCutSeg.end }) }); - }, [currentApparentCutSeg.end, currentApparentCutSeg.start, detectSegments, filePath]); - - const detectSceneChanges = useCallback(async () => { - const filterOptions = await showParametersDialog({ title: i18n.t('Enter parameters'), parameters: ffmpegParameters.sceneChange() }); - if (filterOptions == null) return; - await detectSegments({ name: 'sceneChanges', workingText: i18n.t('Detecting scene changes'), errorText: i18n.t('Failed to detect scene changes'), fn: async () => ffmpegDetectSceneChanges({ filePath, minChange: filterOptions.minChange, onProgress: setCutProgress, from: currentApparentCutSeg.start, to: currentApparentCutSeg.end }) }); - }, [currentApparentCutSeg.end, currentApparentCutSeg.start, detectSegments, filePath]); - - const createSegmentsFromKeyframes = useCallback(async () => { - const keyframes = (await readFrames({ filePath, from: currentApparentCutSeg.start, to: currentApparentCutSeg.end, streamIndex: mainVideoStream?.index })).filter((frame) => frame.keyframe); - const newSegments = mapTimesToSegments(keyframes.map((keyframe) => keyframe.time)); - loadCutSegments(newSegments, true); - }, [currentApparentCutSeg.end, currentApparentCutSeg.start, filePath, loadCutSegments, mainVideoStream?.index]); const userHtml5ifyCurrentFile = useCallback(async ({ ignoreRememberedValue } = {}) => { if (!filePath) return; @@ -1967,30 +1580,6 @@ const App = memo(() => { } }, [customOutDir, filePath, html5ifyAndLoad, hasVideo, hasAudio, rememberConvertToSupportedFormat, setWorking]); - const checkFileOpened = useCallback(() => { - if (isFileOpened) return true; - toast.fire({ icon: 'info', title: i18n.t('You need to open a media file first') }); - return false; - }, [isFileOpened]); - - const createNumSegments = useCallback(async () => { - if (!checkFileOpened() || !isDurationValid(duration)) return; - const segments = await createNumSegmentsDialog(duration); - if (segments) loadCutSegments(segments); - }, [checkFileOpened, duration, loadCutSegments]); - - const createFixedDurationSegments = useCallback(async () => { - if (!checkFileOpened() || !isDurationValid(duration)) return; - const segments = await createFixedDurationSegmentsDialog(duration); - if (segments) loadCutSegments(segments); - }, [checkFileOpened, duration, loadCutSegments]); - - const createRandomSegments = useCallback(async () => { - if (!checkFileOpened() || !isDurationValid(duration)) return; - const segments = await createRandomSegmentsDialog(duration); - if (segments) loadCutSegments(segments); - }, [checkFileOpened, duration, loadCutSegments]); - const askSetStartTimeOffset = useCallback(async () => { const newStartTimeOffset = await promptTimeOffset({ initialValue: startTimeOffset !== undefined ? formatDuration({ seconds: startTimeOffset }) : undefined, diff --git a/src/hooks/useSegments.js b/src/hooks/useSegments.js new file mode 100644 index 00000000..4b853322 --- /dev/null +++ b/src/hooks/useSegments.js @@ -0,0 +1,490 @@ +import { useCallback, useRef, useMemo, useState } from 'react'; +import { useStateWithHistory } from 'react-use/lib/useStateWithHistory'; +import i18n from 'i18next'; +import JSON5 from 'json5'; +import pMap from 'p-map'; + +import sortBy from 'lodash/sortBy'; + +import { blackDetect, silenceDetect, detectSceneChanges as ffmpegDetectSceneChanges, readFrames, mapTimesToSegments, findKeyframeNearTime } from '../ffmpeg'; +import { errorToast, handleError, shuffleArray } from '../util'; +import { showParametersDialog } from '../dialogs/parameters'; +import { createNumSegments as createNumSegmentsDialog, createFixedDurationSegments as createFixedDurationSegmentsDialog, createRandomSegments as createRandomSegmentsDialog, labelSegmentDialog, showEditableJsonDialog, askForShiftSegments, askForAlignSegments, selectSegmentsByLabelDialog } from '../dialogs'; +import { createSegment, findSegmentsAtCursor, sortSegments, invertSegments, getSegmentTags, combineOverlappingSegments as combineOverlappingSegments2, isDurationValid, getSegApparentStart, getSegApparentEnd as getSegApparentEnd2 } from '../segments'; +import * as ffmpegParameters from '../ffmpeg-parameters'; +import { maxSegmentsAllowed } from '../util/constants'; + + +export default ({ + filePath, workingRef, setWorking, setCutProgress, mainVideoStream, + duration, getCurrentTime, maxLabelLength, checkFileOpened, +}) => { + // Segment related state + const segCounterRef = useRef(0); + + const createIndexedSegment = useCallback(({ segment, incrementCount } = {}) => { + if (incrementCount) segCounterRef.current += 1; + const ret = createSegment({ segColorIndex: segCounterRef.current, ...segment }); + return ret; + }, []); + + const createInitialCutSegments = useCallback(() => [createIndexedSegment()], [createIndexedSegment]); + + const [cutSegments, setCutSegments, cutSegmentsHistory] = useStateWithHistory( + createInitialCutSegments(), + 100, + ); + + const [currentSegIndex, setCurrentSegIndex] = useState(0); + const [deselectedSegmentIds, setDeselectedSegmentIds] = useState({}); + + const isSegmentSelected = useCallback(({ segId }) => !deselectedSegmentIds[segId], [deselectedSegmentIds]); + + + const clearSegCounter = useCallback(() => { + // eslint-disable-next-line no-param-reassign + segCounterRef.current = 0; + }, [segCounterRef]); + + const clearSegments = useCallback(() => { + clearSegCounter(); + setCutSegments(createInitialCutSegments()); + }, [clearSegCounter, createInitialCutSegments, setCutSegments]); + + const shuffleSegments = useCallback(() => setCutSegments((oldSegments) => shuffleArray(oldSegments)), [setCutSegments]); + + const loadCutSegments = useCallback((edl, append = false) => { + const validEdl = edl.filter((row) => ( + (row.start === undefined || row.end === undefined || row.start < row.end) + && (row.start === undefined || row.start >= 0) + // TODO: Cannot do this because duration is not yet set when loading a file + // && (row.start === undefined || (row.start >= 0 && row.start < duration)) + // && (row.end === undefined || row.end < duration) + )); + + if (validEdl.length === 0) throw new Error(i18n.t('No valid segments found')); + + if (!append) clearSegCounter(); + + if (validEdl.length > maxSegmentsAllowed) throw new Error(i18n.t('Tried to create too many segments (max {{maxSegmentsAllowed}}.)', { maxSegmentsAllowed })); + + setCutSegments((existingSegments) => { + const needToAppend = append && existingSegments.length > 1; + let newSegments = validEdl.map((segment, i) => createIndexedSegment({ segment, incrementCount: needToAppend || i > 0 })); + if (needToAppend) newSegments = [...existingSegments, ...newSegments]; + return newSegments; + }); + }, [clearSegCounter, createIndexedSegment, setCutSegments]); + + const detectSegments = useCallback(async ({ name, workingText, errorText, fn }) => { + if (!filePath) return; + if (workingRef.current) return; + try { + setWorking(workingText); + setCutProgress(0); + + const newSegments = await fn(); + console.log(name, newSegments); + loadCutSegments(newSegments, true); + } catch (err) { + handleError(errorText, err); + } finally { + setWorking(); + setCutProgress(); + } + }, [filePath, workingRef, setWorking, setCutProgress, loadCutSegments]); + + const getSegApparentEnd = useCallback((seg) => getSegApparentEnd2(seg, duration), [duration]); + + const getApparentCutSegments = useCallback((segments) => segments.map((cutSegment) => ({ + ...cutSegment, + start: getSegApparentStart(cutSegment), + end: getSegApparentEnd(cutSegment), + })), [getSegApparentEnd]); + + // These are segments guaranteed to have a start and end time + const apparentCutSegments = useMemo(() => getApparentCutSegments(cutSegments), [cutSegments, getApparentCutSegments]); + + const haveInvalidSegs = useMemo(() => apparentCutSegments.some((cutSegment) => cutSegment.start >= cutSegment.end), [apparentCutSegments]); + + const currentSegIndexSafe = Math.min(currentSegIndex, cutSegments.length - 1); + const currentCutSeg = useMemo(() => cutSegments[currentSegIndexSafe], [currentSegIndexSafe, cutSegments]); + const currentApparentCutSeg = useMemo(() => apparentCutSegments[currentSegIndexSafe], [apparentCutSegments, currentSegIndexSafe]); + + const selectedSegmentsRaw = useMemo(() => apparentCutSegments.filter(isSegmentSelected), [apparentCutSegments, isSegmentSelected]); + + const detectBlackScenes = useCallback(async () => { + const filterOptions = await showParametersDialog({ title: i18n.t('Enter parameters'), parameters: ffmpegParameters.blackdetect(), docUrl: 'https://ffmpeg.org/ffmpeg-filters.html#blackdetect' }); + if (filterOptions == null) return; + await detectSegments({ name: 'blackScenes', workingText: i18n.t('Detecting black scenes'), errorText: i18n.t('Failed to detect black scenes'), fn: async () => blackDetect({ filePath, filterOptions, onProgress: setCutProgress, from: currentApparentCutSeg.start, to: currentApparentCutSeg.end }) }); + }, [currentApparentCutSeg.end, currentApparentCutSeg.start, detectSegments, filePath, setCutProgress]); + + const detectSilentScenes = useCallback(async () => { + const filterOptions = await showParametersDialog({ title: i18n.t('Enter parameters'), parameters: ffmpegParameters.silencedetect(), docUrl: 'https://ffmpeg.org/ffmpeg-filters.html#silencedetect' }); + if (filterOptions == null) return; + await detectSegments({ name: 'silentScenes', workingText: i18n.t('Detecting silent scenes'), errorText: i18n.t('Failed to detect silent scenes'), fn: async () => silenceDetect({ filePath, filterOptions, onProgress: setCutProgress, from: currentApparentCutSeg.start, to: currentApparentCutSeg.end }) }); + }, [currentApparentCutSeg.end, currentApparentCutSeg.start, detectSegments, filePath, setCutProgress]); + + const detectSceneChanges = useCallback(async () => { + const filterOptions = await showParametersDialog({ title: i18n.t('Enter parameters'), parameters: ffmpegParameters.sceneChange() }); + if (filterOptions == null) return; + await detectSegments({ name: 'sceneChanges', workingText: i18n.t('Detecting scene changes'), errorText: i18n.t('Failed to detect scene changes'), fn: async () => ffmpegDetectSceneChanges({ filePath, minChange: filterOptions.minChange, onProgress: setCutProgress, from: currentApparentCutSeg.start, to: currentApparentCutSeg.end }) }); + }, [currentApparentCutSeg.end, currentApparentCutSeg.start, detectSegments, filePath, setCutProgress]); + + const createSegmentsFromKeyframes = useCallback(async () => { + if (!mainVideoStream) return; + const keyframes = (await readFrames({ filePath, from: currentApparentCutSeg.start, to: currentApparentCutSeg.end, streamIndex: mainVideoStream.index })).filter((frame) => frame.keyframe); + const newSegments = mapTimesToSegments(keyframes.map((keyframe) => keyframe.time)); + loadCutSegments(newSegments, true); + }, [currentApparentCutSeg.end, currentApparentCutSeg.start, filePath, loadCutSegments, mainVideoStream]); + + const removeSegments = useCallback((removeSegmentIds) => { + if (cutSegments.length === 1 && cutSegments[0].start == null && cutSegments[0].end == null) return; // We are at initial segment, nothing more we can do (it cannot be removed) + setCutSegments((existing) => { + const newSegments = existing.filter((seg) => !removeSegmentIds.includes(seg.segId)); + if (newSegments.length === 0) { + clearSegments(); // when removing the last segments, we start over + return existing; + } + return newSegments; + }); + }, [clearSegments, cutSegments, setCutSegments]); + + const removeCutSegment = useCallback((index) => { + removeSegments([cutSegments[index].segId]); + }, [cutSegments, removeSegments]); + + const inverseCutSegments = useMemo(() => { + const inverted = !haveInvalidSegs && isDurationValid(duration) ? invertSegments(sortSegments(apparentCutSegments), true, true, duration) : undefined; + return (inverted || []).map((seg) => ({ ...seg, segId: `${seg.start}-${seg.end}` })); + }, [apparentCutSegments, duration, haveInvalidSegs]); + + const invertAllSegments = useCallback(() => { + if (inverseCutSegments.length < 1) { + errorToast(i18n.t('Make sure you have no overlapping segments.')); + return; + } + // don't reset segColorIndex (which represent colors) when inverting + const newInverseCutSegments = inverseCutSegments.map((inverseSegment, index) => createSegment({ ...inverseSegment, segColorIndex: index })); + setCutSegments(newInverseCutSegments); + }, [inverseCutSegments, setCutSegments]); + + const fillSegmentsGaps = useCallback(() => { + if (inverseCutSegments.length < 1) { + errorToast(i18n.t('Make sure you have no overlapping segments.')); + return; + } + const newInverseCutSegments = inverseCutSegments.map((inverseSegment) => createIndexedSegment({ segment: inverseSegment, incrementCount: true })); + setCutSegments((existing) => ([...existing, ...newInverseCutSegments])); + }, [createIndexedSegment, inverseCutSegments, setCutSegments]); + + const combineOverlappingSegments = useCallback(() => { + setCutSegments((existingSegments) => combineOverlappingSegments2(existingSegments, getSegApparentEnd)); + }, [getSegApparentEnd, setCutSegments]); + + const updateSegAtIndex = useCallback((index, newProps) => { + if (index < 0) return; + const cutSegmentsNew = [...cutSegments]; + cutSegmentsNew.splice(index, 1, { ...cutSegments[index], ...newProps }); + setCutSegments(cutSegmentsNew); + }, [setCutSegments, cutSegments]); + + const setCutTime = useCallback((type, time) => { + if (!isDurationValid(duration)) return; + + const currentSeg = currentCutSeg; + if (type === 'start' && time >= getSegApparentEnd(currentSeg)) { + throw new Error('Start time must precede end time'); + } + if (type === 'end' && time <= getSegApparentStart(currentSeg)) { + throw new Error('Start time must precede end time'); + } + updateSegAtIndex(currentSegIndexSafe, { [type]: Math.min(Math.max(time, 0), duration) }); + }, [currentSegIndexSafe, getSegApparentEnd, currentCutSeg, duration, updateSegAtIndex]); + + const modifySelectedSegmentTimes = useCallback(async (transformSegment, concurrency = 5) => { + const clampValue = (val) => Math.min(Math.max(val, 0), duration); + + let 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; + }, { concurrency }); + newSegments = newSegments.filter((segment) => segment.end > segment.start); + if (newSegments.length < 1) setCutSegments(createInitialCutSegments()); + else setCutSegments(newSegments); + }, [apparentCutSegments, createInitialCutSegments, duration, isSegmentSelected, setCutSegments]); + + const shiftAllSegmentTimes = useCallback(async () => { + const shift = await askForShiftSegments(); + if (shift == null) return; + + const { shiftAmount, shiftKeys } = shift; + await modifySelectedSegmentTimes((segment) => { + const newSegment = { ...segment }; + shiftKeys.forEach((key) => { + newSegment[key] += shiftAmount; + }); + return newSegment; + }); + }, [modifySelectedSegmentTimes]); + + const alignSegmentTimesToKeyframes = useCallback(async () => { + if (!mainVideoStream || workingRef.current) return; + try { + const response = await askForAlignSegments(); + if (response == null) return; + setWorking(i18n.t('Aligning segments to keyframes')); + const { mode, startOrEnd } = response; + await modifySelectedSegmentTimes(async (segment) => { + const newSegment = { ...segment }; + + async function align(key) { + const time = newSegment[key]; + const keyframe = await findKeyframeNearTime({ filePath, streamIndex: mainVideoStream.index, time, mode }); + if (!keyframe == null) throw new Error(`Cannot find any keyframe within 60 seconds of frame ${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(); + } + }, [filePath, mainVideoStream, modifySelectedSegmentTimes, setWorking, workingRef]); + + const onViewSegmentTags = useCallback(async (index) => { + const segment = cutSegments[index]; + function inputValidator(jsonStr) { + try { + const json = JSON5.parse(jsonStr); + if (!(typeof json === 'object' && Object.values(json).every((val) => typeof val === 'string'))) throw new Error(); + return undefined; + } catch (err) { + return i18n.t('Invalid JSON'); + } + } + const tags = getSegmentTags(segment); + const newTagsStr = await showEditableJsonDialog({ title: i18n.t('Segment tags'), text: i18n.t('View and edit segment tags in JSON5 format:'), inputValue: Object.keys(tags).length > 0 ? JSON5.stringify(tags, null, 2) : '', inputValidator }); + if (newTagsStr != null) updateSegAtIndex(index, { tags: JSON5.parse(newTagsStr) }); + }, [cutSegments, updateSegAtIndex]); + + const updateSegOrder = useCallback((index, newOrder) => { + if (newOrder > cutSegments.length - 1 || newOrder < 0) return; + const newSegments = [...cutSegments]; + const removedSeg = newSegments.splice(index, 1)[0]; + newSegments.splice(newOrder, 0, removedSeg); + setCutSegments(newSegments); + setCurrentSegIndex(newOrder); + }, [cutSegments, setCurrentSegIndex, setCutSegments]); + + const updateSegOrders = useCallback((newOrders) => { + const newSegments = sortBy(cutSegments, (seg) => newOrders.indexOf(seg.segId)); + const newCurrentSegIndex = newOrders.indexOf(currentCutSeg.segId); + setCutSegments(newSegments); + if (newCurrentSegIndex >= 0 && newCurrentSegIndex < newSegments.length) setCurrentSegIndex(newCurrentSegIndex); + }, [cutSegments, setCutSegments, currentCutSeg, setCurrentSegIndex]); + + const reorderSegsByStartTime = useCallback(() => { + setCutSegments(sortBy(cutSegments, getSegApparentStart)); + }, [cutSegments, setCutSegments]); + + const addSegment = useCallback(() => { + try { + // Cannot add if prev seg is not finished + if (currentCutSeg.start === undefined && currentCutSeg.end === undefined) return; + + const suggestedStart = getCurrentTime(); + /* if (keyframeCut) { + const keyframeAlignedStart = getSafeCutTime(suggestedStart, true); + if (keyframeAlignedStart != null) suggestedStart = keyframeAlignedStart; + } */ + + if (suggestedStart >= duration) return; + + const cutSegmentsNew = [ + ...cutSegments, + createIndexedSegment({ segment: { start: suggestedStart }, incrementCount: true }), + ]; + + setCutSegments(cutSegmentsNew); + setCurrentSegIndex(cutSegmentsNew.length - 1); + } catch (err) { + console.error(err); + } + }, [currentCutSeg.start, currentCutSeg.end, getCurrentTime, duration, cutSegments, createIndexedSegment, setCutSegments, setCurrentSegIndex]); + + const setCutStart = useCallback(() => { + if (!checkFileOpened()) return; + + const currentTime = getCurrentTime(); + // https://github.com/mifi/lossless-cut/issues/168 + // If current time is after the end of the current segment in the timeline, + // add a new segment that starts at playerTime + if (currentCutSeg.end != null && currentTime >= currentCutSeg.end) { + addSegment(); + } else { + try { + const startTime = currentTime; + /* if (keyframeCut) { + const keyframeAlignedCutTo = getSafeCutTime(startTime, true); + if (keyframeAlignedCutTo != null) startTime = keyframeAlignedCutTo; + } */ + setCutTime('start', startTime); + } catch (err) { + handleError(err); + } + } + }, [checkFileOpened, getCurrentTime, currentCutSeg.end, addSegment, setCutTime]); + + const setCutEnd = useCallback(() => { + if (!checkFileOpened()) return; + + try { + const endTime = getCurrentTime(); + + /* if (keyframeCut) { + const keyframeAlignedCutTo = getSafeCutTime(endTime, false); + if (keyframeAlignedCutTo != null) endTime = keyframeAlignedCutTo; + } */ + setCutTime('end', endTime); + } catch (err) { + handleError(err); + } + }, [checkFileOpened, getCurrentTime, setCutTime]); + + const onLabelSegment = useCallback(async (index) => { + const { name } = cutSegments[index]; + const value = await labelSegmentDialog({ currentName: name, maxLength: maxLabelLength }); + if (value != null) updateSegAtIndex(index, { name: value }); + }, [cutSegments, updateSegAtIndex, maxLabelLength]); + + const splitCurrentSegment = useCallback(() => { + const currentTime = getCurrentTime(); + const segmentsAtCursorIndexes = findSegmentsAtCursor(apparentCutSegments, currentTime); + + if (segmentsAtCursorIndexes.length === 0) { + errorToast(i18n.t('No segment to split. Please move cursor over the segment you want to split')); + return; + } + + const firstSegmentAtCursorIndex = segmentsAtCursorIndexes[0]; + const segment = cutSegments[firstSegmentAtCursorIndex]; + + const getNewName = (oldName, suffix) => oldName && `${segment.name} ${suffix}`; + + const firstPart = createIndexedSegment({ segment: { name: getNewName(segment.name, '1'), start: segment.start, end: currentTime }, incrementCount: false }); + const secondPart = createIndexedSegment({ segment: { name: getNewName(segment.name, '2'), start: currentTime, end: segment.end }, incrementCount: true }); + + const newSegments = [...cutSegments]; + newSegments.splice(firstSegmentAtCursorIndex, 1, firstPart, secondPart); + setCutSegments(newSegments); + }, [apparentCutSegments, createIndexedSegment, cutSegments, getCurrentTime, setCutSegments]); + + const createNumSegments = useCallback(async () => { + if (!checkFileOpened() || !isDurationValid(duration)) return; + const segments = await createNumSegmentsDialog(duration); + if (segments) loadCutSegments(segments); + }, [checkFileOpened, duration, loadCutSegments]); + + const createFixedDurationSegments = useCallback(async () => { + if (!checkFileOpened() || !isDurationValid(duration)) return; + const segments = await createFixedDurationSegmentsDialog(duration); + if (segments) loadCutSegments(segments); + }, [checkFileOpened, duration, loadCutSegments]); + + const createRandomSegments = useCallback(async () => { + if (!checkFileOpened() || !isDurationValid(duration)) return; + const segments = await createRandomSegmentsDialog(duration); + if (segments) loadCutSegments(segments); + }, [checkFileOpened, duration, loadCutSegments]); + + const onSelectSegmentsByLabel = useCallback(async () => { + const { name } = currentCutSeg; + const value = await selectSegmentsByLabelDialog(name); + if (value == null) return; + const segmentsToEnable = cutSegments.filter((seg) => (seg.name || '') === value); + if (segmentsToEnable.length === 0 || segmentsToEnable.length === cutSegments.length) return; // no point + setDeselectedSegmentIds((existing) => { + const ret = { ...existing }; + segmentsToEnable.forEach(({ segId }) => { ret[segId] = false; }); + return ret; + }); + }, [currentCutSeg, cutSegments]); + + const onLabelSelectedSegments = useCallback(async () => { + if (selectedSegmentsRaw.length < 1) return; + const { name } = selectedSegmentsRaw[0]; + const value = await labelSegmentDialog({ currentName: name, maxLength: maxLabelLength }); + setCutSegments((existingSegments) => existingSegments.map((existingSegment) => { + if (selectedSegmentsRaw.some((seg) => seg.segId === existingSegment.segId)) return { ...existingSegment, name: value }; + return existingSegment; + })); + }, [maxLabelLength, selectedSegmentsRaw, setCutSegments]); + + const removeSelectedSegments = useCallback(() => removeSegments(selectedSegmentsRaw.map((seg) => seg.segId)), [removeSegments, selectedSegmentsRaw]); + + const selectOnlySegment = useCallback((seg) => setDeselectedSegmentIds(Object.fromEntries(cutSegments.filter((s) => s.segId !== seg.segId).map((s) => [s.segId, true]))), [cutSegments]); + const toggleSegmentSelected = useCallback((seg) => setDeselectedSegmentIds((existing) => ({ ...existing, [seg.segId]: !existing[seg.segId] })), []); + const deselectAllSegments = useCallback(() => setDeselectedSegmentIds(Object.fromEntries(cutSegments.map((s) => [s.segId, true]))), [cutSegments]); + const selectAllSegments = useCallback(() => setDeselectedSegmentIds({}), []); + + const selectOnlyCurrentSegment = useCallback(() => selectOnlySegment(currentCutSeg), [currentCutSeg, selectOnlySegment]); + const toggleCurrentSegmentSelected = useCallback(() => toggleSegmentSelected(currentCutSeg), [currentCutSeg, toggleSegmentSelected]); + + return { + cutSegments, + cutSegmentsHistory, + createSegmentsFromKeyframes, + shuffleSegments, + detectBlackScenes, + detectSilentScenes, + detectSceneChanges, + removeCutSegment, + invertAllSegments, + fillSegmentsGaps, + combineOverlappingSegments, + shiftAllSegmentTimes, + alignSegmentTimesToKeyframes, + onViewSegmentTags, + updateSegOrder, + updateSegOrders, + reorderSegsByStartTime, + addSegment, + setCutStart, + setCutEnd, + onLabelSegment, + splitCurrentSegment, + createNumSegments, + createFixedDurationSegments, + createRandomSegments, + apparentCutSegments, + haveInvalidSegs, + currentSegIndexSafe, + currentCutSeg, + currentApparentCutSeg, + inverseCutSegments, + clearSegments, + loadCutSegments, + selectedSegmentsRaw, + setCutTime, + getSegApparentEnd, + setCurrentSegIndex, + + setDeselectedSegmentIds, + onLabelSelectedSegments, + deselectAllSegments, + selectAllSegments, + selectOnlyCurrentSegment, + toggleCurrentSegmentSelected, + removeSelectedSegments, + onSelectSegmentsByLabel, + toggleSegmentSelected, + selectOnlySegment, + }; +};