From 250218bf5fc88100d66ac5491349a3c4e6a23bdb Mon Sep 17 00:00:00 2001 From: Mikael Finstad Date: Tue, 18 Feb 2025 00:27:27 +0800 Subject: [PATCH] Feature: Markers Segments that don't have any end time are now considered markers and rendered differently. Markers are not exported. All segments will now have a start time defined - llc project file version bumped to v2 (to migrate missing `start` times) Also: Removed old csv project auto import --- src/main/ffmpeg.ts | 12 +- src/renderer/src/App.tsx | 72 +++---- src/renderer/src/BottomBar.tsx | 57 ++++-- src/renderer/src/SegmentList.tsx | 62 +++--- src/renderer/src/Timeline.tsx | 6 - src/renderer/src/TimelineSeg.tsx | 111 ++++++++--- src/renderer/src/components/ExportButton.tsx | 5 +- src/renderer/src/dialogs/index.tsx | 15 +- src/renderer/src/edlFormats.ts | 6 +- src/renderer/src/edlStore.ts | 37 ++-- src/renderer/src/hooks/useSegments.ts | 178 ++++++++++-------- src/renderer/src/hooks/useSegmentsAutoSave.ts | 4 - src/renderer/src/segments.test.ts | 6 +- src/renderer/src/segments.ts | 71 +++---- src/renderer/src/types.ts | 27 ++- types.ts | 2 +- 16 files changed, 397 insertions(+), 274 deletions(-) diff --git a/src/main/ffmpeg.ts b/src/main/ffmpeg.ts index 7775773c..b7aff888 100644 --- a/src/main/ffmpeg.ts +++ b/src/main/ffmpeg.ts @@ -254,10 +254,8 @@ export function mapTimesToSegments(times: number[], includeLast: boolean) { return segments; } -const getSegmentOffset = (from?: number) => (from != null ? from : 0); - function adjustSegmentsWithOffset({ segments, from }: { segments: { start: number, end: number | undefined }[], from?: number | undefined }) { - const offset = getSegmentOffset(from); + const offset = from ?? 0; return segments.map(({ start, end }) => ({ start: start + offset, end: end != null ? end + offset : end })); } @@ -296,7 +294,13 @@ export async function detectSceneChanges({ filePath, minChange, onProgress, from } async function detectIntervals({ filePath, customArgs, onProgress, from, to, matchLineTokens, boundingMode }: { - filePath: string, customArgs: string[], onProgress: (p: number) => void, from: number, to: number, matchLineTokens: (line: string) => { start: number, end: number } | undefined, boundingMode: boolean + filePath: string, + customArgs: string[], + onProgress: (p: number) => void, + from: number, + to: number, + matchLineTokens: (line: string) => { start: number, end: number } | undefined, + boundingMode: boolean, }) { const args = [ '-hide_banner', diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 5ee65eab..cf8f63f8 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -82,7 +82,7 @@ import { askForHtml5ifySpeed } from './dialogs/html5ify'; import { askForOutDir, askForImportChapters, promptTimecode, askForFileOpenAction, confirmExtractAllStreamsDialog, showCleanupFilesDialog, showDiskFull, showExportFailedDialog, showConcatFailedDialog, openYouTubeChaptersDialog, showRefuseToOverwrite, openDirToast, openExportFinishedToast, openConcatFinishedToast, showOpenDialog, showMuxNotSupported, promptDownloadMediaUrl, CleanupChoicesType, showOutputNotWritable } from './dialogs'; import { openSendReportDialog } from './reporting'; import { fallbackLng } from './i18n'; -import { findSegmentsAtCursor, sortSegments, convertSegmentsToChapters, hasAnySegmentOverlap, isDurationValid, getPlaybackMode, getSegmentTags, getSegApparentStart } from './segments'; +import { sortSegments, convertSegmentsToChapters, hasAnySegmentOverlap, isDurationValid, getPlaybackMode, getSegmentTags, filterNonMarkers } from './segments'; import { generateOutSegFileNames as generateOutSegFileNamesRaw, generateMergedFileNames as generateMergedFileNamesRaw, defaultOutSegTemplate, defaultCutMergedFileTemplate } from './util/outputNameTemplate'; import { rightBarWidth, leftBarWidth, ffmpegExtractWindow, zoomMax } from './util/constants'; import BigWaveform from './components/BigWaveform'; @@ -335,20 +335,20 @@ function App() { }, [isFileOpened]); const { - cutSegments, cutSegmentsHistory, createSegmentsFromKeyframes, shuffleSegments, detectBlackScenes, detectSilentScenes, detectSceneChanges, removeCutSegment, invertAllSegments, fillSegmentsGaps, combineOverlappingSegments, combineSelectedSegments, shiftAllSegmentTimes, alignSegmentTimesToKeyframes, updateSegOrder, updateSegOrders, reorderSegsByStartTime, addSegment, setCutStart, setCutEnd, onLabelSegment, splitCurrentSegment, focusSegmentAtCursor, createNumSegments, createFixedDurationSegments, createRandomSegments, haveInvalidSegs, currentSegIndexSafe, currentCutSeg, inverseCutSegments, clearSegments, loadCutSegments, isSegmentSelected, setCutTime, setCurrentSegIndex, onLabelSelectedSegments, deselectAllSegments, selectAllSegments, selectOnlyCurrentSegment, toggleCurrentSegmentSelected, invertSelectedSegments, removeSelectedSegments, setDeselectedSegmentIds, onSelectSegmentsByLabel, onSelectSegmentsByExpr, onMutateSegmentsByExpr, toggleSegmentSelected, selectOnlySegment, selectedSegments, selectedSegmentsOrInverse, nonFilteredSegmentsOrInverse, segmentsToExport, duplicateCurrentSegment, duplicateSegment, updateSegAtIndex, getSegApparentEnd, getApparentCutSegments, - } = useSegments({ filePath, workingRef, setWorking, setProgress, videoStream: activeVideoStream, duration, getRelevantTime, maxLabelLength, checkFileOpened, invertCutSegments, segmentsToChaptersOnly, timecodePlaceholder, parseTimecode, appendFfmpegCommandLog }); + cutSegments, cutSegmentsHistory, createSegmentsFromKeyframes, shuffleSegments, detectBlackScenes, detectSilentScenes, detectSceneChanges, removeCutSegment, invertAllSegments, fillSegmentsGaps, combineOverlappingSegments, combineSelectedSegments, shiftAllSegmentTimes, alignSegmentTimesToKeyframes, updateSegOrder, updateSegOrders, reorderSegsByStartTime, addSegment, setCutStart, setCutEnd, onLabelSegment, splitCurrentSegment, focusSegmentAtCursor, createNumSegments, createFixedDurationSegments, createRandomSegments, haveInvalidSegs, currentSegIndexSafe, currentCutSeg, inverseCutSegments, clearSegments, loadCutSegments, isSegmentSelected, setCutTime, setCurrentSegIndex, onLabelSelectedSegments, deselectAllSegments, selectAllSegments, selectOnlyCurrentSegment, toggleCurrentSegmentSelected, invertSelectedSegments, removeSelectedSegments, setDeselectedSegmentIds, onSelectSegmentsByLabel, onSelectSegmentsByExpr, onMutateSegmentsByExpr, toggleSegmentSelected, selectOnlySegment, selectedSegments, selectedSegmentsOrInverse, nonFilteredSegmentsOrInverse, segmentsToExport, duplicateCurrentSegment, duplicateSegment, updateSegAtIndex, findSegmentsAtCursor, + } = useSegments({ filePath, workingRef, setWorking, setProgress, videoStream: activeVideoStream, duration, getRelevantTime, maxLabelLength, checkFileOpened, invertCutSegments, segmentsToChaptersOnly, timecodePlaceholder, parseTimecode, appendFfmpegCommandLog, durationSafe }); - const { getEdlFilePath, getEdlFilePathOld, projectFileSavePath, getProjectFileSavePath } = useSegmentsAutoSave({ autoSaveProjectFile, storeProjectInWorkingDir, filePath, customOutDir, cutSegments }); + const { getEdlFilePath, projectFileSavePath, getProjectFileSavePath } = useSegmentsAutoSave({ autoSaveProjectFile, storeProjectInWorkingDir, filePath, customOutDir, cutSegments }); const { nonCopiedExtraStreams, exportExtraStreams, mainCopiedThumbnailStreams, numStreamsToCopy, toggleStripAudio, toggleStripThumbnail, copyAnyAudioTrack, copyStreamIdsByFile, setCopyStreamIdsByFile, copyFileStreams, mainCopiedStreams, setCopyStreamIdsForPath, toggleCopyStreamId, isCopyingStreamId } = useStreamsMeta({ mainStreams, filePath, autoExportExtraStreams }); const segmentAtCursor = useMemo(() => { - const segmentsAtCursorIndexes = findSegmentsAtCursor(cutSegments, commandedTime); + const segmentsAtCursorIndexes = findSegmentsAtCursor(commandedTime); const firstSegmentAtCursorIndex = segmentsAtCursorIndexes[0]; if (firstSegmentAtCursorIndex == null) return undefined; return cutSegments[firstSegmentAtCursorIndex]; - }, [cutSegments, commandedTime]); + }, [commandedTime, cutSegments, findSegmentsAtCursor]); const segmentAtCursorRef = useRef(); useEffect(() => { @@ -371,8 +371,11 @@ function App() { seekAbs(nextFrame / fps); }, [detectedFps, seekAbs, videoRef]); - const jumpSegStart = useCallback((index: number) => seekAbs(getSegApparentStart(cutSegments[index]!)), [cutSegments, seekAbs]); - const jumpSegEnd = useCallback((index: number) => seekAbs(getSegApparentEnd(cutSegments[index]!)), [cutSegments, getSegApparentEnd, seekAbs]); + const jumpSegStart = useCallback((index: number) => seekAbs(cutSegments[index]!.start), [cutSegments, seekAbs]); + const jumpSegEnd = useCallback((index: number) => { + const seg = cutSegments[index]; + if (seg?.end != null) seekAbs(seg.end); + }, [cutSegments, seekAbs]); const jumpCutStart = useCallback(() => jumpSegStart(currentSegIndexSafe), [currentSegIndexSafe, jumpSegStart]); const jumpCutEnd = useCallback(() => jumpSegEnd(currentSegIndexSafe), [currentSegIndexSafe, jumpSegEnd]); const jumpTimelineStart = useCallback(() => seekAbs(0), [seekAbs]); @@ -701,7 +704,7 @@ function App() { // If we are using a special playback mode, we might need to do more: if (playbackModeRef.current != null) { const selectedSegmentAtCursor = selectedSegments.find((selectedSegment) => selectedSegment.segId === segmentAtCursorRef.current?.segId); - const isSomeSegmentAtCursor = selectedSegmentAtCursor != null && commandedTimeRef.current != null && getSegApparentEnd(selectedSegmentAtCursor) - commandedTimeRef.current > 0.1; + const isSomeSegmentAtCursor = selectedSegmentAtCursor != null && commandedTimeRef.current != null && selectedSegmentAtCursor.end != null && selectedSegmentAtCursor.end - commandedTimeRef.current > 0.1; if (!isSomeSegmentAtCursor) { // if a segment is already at cursor, don't do anything // if no segment at cursor, and looping playback mode, continue looping if (playbackModeRef.current === 'loop-selected-segments') { @@ -712,12 +715,12 @@ function App() { seekAbs(firstSelectedSegment.start); } else { // for all other playback modes, seek to start of current segment - seekAbs(getSegApparentStart(currentCutSeg)); + seekAbs(currentCutSeg.start); } } } play(resetPlaybackRate); - }, [playbackModeRef, playingRef, play, pause, selectedSegments, commandedTimeRef, getSegApparentEnd, cutSegments, setCurrentSegIndex, seekAbs, currentCutSeg]); + }, [playbackModeRef, playingRef, play, pause, selectedSegments, commandedTimeRef, cutSegments, setCurrentSegIndex, seekAbs, currentCutSeg]); const onTimeUpdate = useCallback>((e) => { const { currentTime } = e.currentTarget; @@ -726,12 +729,12 @@ function App() { const playbackMode = playbackModeRef.current; - const segmentsAtCursorIndexes = findSegmentsAtCursor(cutSegments, commandedTimeRef.current); + const segmentsAtCursorIndexes = findSegmentsAtCursor(commandedTimeRef.current); const firstSegmentAtCursorIndex = segmentsAtCursorIndexes[0]; const playingSegment = firstSegmentAtCursorIndex != null ? cutSegments[firstSegmentAtCursorIndex] : undefined; - if (playbackMode != null && playingSegment) { // todo and is currently playing? - const nextAction = getPlaybackMode({ playbackMode, currentTime, playingSegment: { start: getSegApparentStart(playingSegment), end: getSegApparentEnd(playingSegment) } }); + if (playbackMode != null && playingSegment && playingSegment.end != null) { // todo and is currently playing? + const nextAction = getPlaybackMode({ playbackMode, currentTime, playingSegment: { start: playingSegment.start, end: playingSegment.end } }); if (nextAction != null) { console.log(nextAction); @@ -751,7 +754,7 @@ function App() { } } } - }, [commandedTimeRef, cutSegments, getSegApparentEnd, pause, playbackModeRef, playerTime, seekAbs, selectedSegments, setPlayerTime]); + }, [commandedTimeRef, cutSegments, findSegmentsAtCursor, pause, playbackModeRef, playerTime, seekAbs, selectedSegments, setPlayerTime]); const closeFileWithConfirm = useCallback(() => { if (!isFileOpened || workingRef.current) return; @@ -981,6 +984,7 @@ function App() { } if (segmentsToExport.length === 0) { + console.warn('Nothing to export'); return; } @@ -999,7 +1003,7 @@ function App() { // Special segments-to-chapters mode: let chaptersToAdd: Chapter[] | undefined; if (segmentsToChaptersOnly) { - const sortedSegments = sortSegments(getApparentCutSegments(selectedSegmentsOrInverse)); + const sortedSegments = sortSegments(selectedSegmentsOrInverse); if (hasAnySegmentOverlap(sortedSegments)) { errorToast(i18n.t('Make sure you have no overlapping segments.')); return; @@ -1145,7 +1149,7 @@ function App() { setWorking(undefined); setProgress(undefined); } - }, [filePath, numStreamsToCopy, segmentsToExport, haveInvalidSegs, workingRef, setWorking, segmentsToChaptersOnly, outSegTemplateOrDefault, generateOutSegFileNames, cutMultiple, outputDir, customOutDir, fileFormat, duration, isRotationSet, effectiveRotation, copyFileStreams, allFilesMeta, keyframeCut, shortestFlag, ffmpegExperimental, preserveMetadata, preserveMetadataOnMerge, preserveMovData, preserveChapters, movFastStart, avoidNegativeTs, customTagsByFile, paramsByStreamId, detectedFps, willMerge, enableOverwriteOutput, exportConfirmEnabled, mainFileFormatData, mainStreams, exportExtraStreams, areWeCutting, hideAllNotifications, cleanupChoices.cleanupAfterExport, cleanupFilesWithDialog, getApparentCutSegments, selectedSegmentsOrInverse, t, mergedFileTemplateOrDefault, segmentsToChapters, invertCutSegments, generateMergedFileNames, autoConcatCutSegments, autoDeleteMergedSegments, nonCopiedExtraStreams, extractStreams, showOsNotification, handleExportFailed]); + }, [filePath, numStreamsToCopy, segmentsToExport, haveInvalidSegs, workingRef, setWorking, segmentsToChaptersOnly, outSegTemplateOrDefault, generateOutSegFileNames, cutMultiple, outputDir, customOutDir, fileFormat, duration, isRotationSet, effectiveRotation, copyFileStreams, allFilesMeta, keyframeCut, shortestFlag, ffmpegExperimental, preserveMetadata, preserveMetadataOnMerge, preserveMovData, preserveChapters, movFastStart, avoidNegativeTs, customTagsByFile, paramsByStreamId, detectedFps, willMerge, enableOverwriteOutput, exportConfirmEnabled, mainFileFormatData, mainStreams, exportExtraStreams, areWeCutting, hideAllNotifications, cleanupChoices.cleanupAfterExport, cleanupFilesWithDialog, selectedSegmentsOrInverse, t, mergedFileTemplateOrDefault, segmentsToChapters, invertCutSegments, generateMergedFileNames, autoConcatCutSegments, autoDeleteMergedSegments, nonCopiedExtraStreams, extractStreams, showOsNotification, handleExportFailed]); const onExportPress = useCallback(async () => { if (!filePath) return; @@ -1174,16 +1178,15 @@ function App() { }, i18n.t('Failed to capture frame')); }, [filePath, getRelevantTime, videoRef, usingPreviewFile, captureFrameMethod, captureFrameFromFfmpeg, customOutDir, captureFormat, captureFrameQuality, captureFrameFromTag, hideAllNotifications]); - const extractSegmentFramesAsImages = useCallback(async (segIds: string[]) => { + const extractSegmentFramesAsImages = useCallback(async (segments: { start: number, end: number }[]) => { if (!filePath || detectedFps == null || workingRef.current) return; - const segments = getApparentCutSegments(cutSegments.filter((seg) => segIds.includes(seg.segId))); const segmentsNumFrames = segments.reduce((acc, { start, end }) => acc + (getFrameCount(end - start) ?? 0), 0); const captureFramesResponse = await askExtractFramesAsImages({ segmentsNumFrames, plural: segments.length > 1, fps: detectedFps }); if (captureFramesResponse == null) return; try { setWorking({ text: i18n.t('Extracting frames') }); - console.log('Extracting frames as images', { segIds, captureFramesResponse }); + console.log('Extracting frames as images', { captureFramesResponse }); setProgress(0); @@ -1214,10 +1217,15 @@ function App() { setWorking(undefined); setProgress(undefined); } - }, [filePath, detectedFps, workingRef, getApparentCutSegments, cutSegments, getFrameCount, setWorking, hideAllNotifications, captureFramesRange, customOutDir, captureFormat, captureFrameQuality, captureFrameFileNameFormat, showOsNotification, outputDir]); + }, [filePath, detectedFps, workingRef, getFrameCount, setWorking, hideAllNotifications, captureFramesRange, customOutDir, captureFormat, captureFrameQuality, captureFrameFileNameFormat, showOsNotification, outputDir]); + + const extractCurrentSegmentFramesAsImages = useCallback(() => { + const { end } = currentCutSeg; + invariant(currentCutSeg != null && end != null); + extractSegmentFramesAsImages([{ ...currentCutSeg, end }]); + }, [currentCutSeg, extractSegmentFramesAsImages]); - const extractCurrentSegmentFramesAsImages = useCallback(() => extractSegmentFramesAsImages([currentCutSeg?.segId]), [currentCutSeg?.segId, extractSegmentFramesAsImages]); - const extractSelectedSegmentsFramesAsImages = useCallback(() => extractSegmentFramesAsImages(selectedSegments.map((seg) => seg.segId)), [extractSegmentFramesAsImages, selectedSegments]); + const extractSelectedSegmentsFramesAsImages = useCallback(() => extractSegmentFramesAsImages(filterNonMarkers(selectedSegments)), [extractSegmentFramesAsImages, selectedSegments]); const userChangePlaybackRate = useCallback((dir: number, rateMultiplier?: number) => { if (compatPlayerEnabled) { @@ -1251,9 +1259,9 @@ function App() { }, [filePath, loadCutSegments, setWorking]); const loadMedia = useCallback(async ({ filePath: fp, projectPath }: { filePath: string, projectPath?: string | undefined }) => { - async function tryOpenProjectPath(path: string, type: EdlFileType) { + async function tryOpenProjectPath(path: string) { if (!(await exists(path))) return false; - await loadEdlFile({ path, type }); + await loadEdlFile({ path, type: 'llc' }); return true; } @@ -1262,7 +1270,7 @@ function App() { async function tryFindAndLoadProjectFile({ chapters, cod }: { chapters: FFprobeChapter[], cod: string | undefined }) { try { // First try to open from from working dir - if (await tryOpenProjectPath(getEdlFilePath(fp, cod), 'llc')) return; + if (await tryOpenProjectPath(getEdlFilePath(fp, cod))) return; // then try to open project from source file dir const sameDirEdlFilePath = getEdlFilePath(fp); @@ -1275,9 +1283,6 @@ function App() { await loadEdlFile({ path: sameDirEdlFilePath, type: 'llc' }); } - // then finally old csv style project - if (await tryOpenProjectPath(getEdlFilePathOld(fp, cod), 'csv')) return; - // OK, we didn't find a project file, instead maybe try to create project (segments) from chapters const edl = tryMapChaptersToEdl(chapters); if (edl.length > 0 && enableAskForImportChapters && (await askForImportChapters())) { @@ -1410,7 +1415,7 @@ function App() { resetState(); throw err; } - }, [storeProjectInWorkingDir, setWorking, loadEdlFile, getEdlFilePath, getEdlFilePathOld, enableAskForImportChapters, ensureAccessToSourceDir, loadCutSegments, autoLoadTimecode, enableNativeHevc, ensureWritableOutDir, customOutDir, resetState, setCopyStreamIdsForPath, setFileFormat, outFormatLocked, setDetectedFileFormat, html5ifyAndLoadWithPreferences, showPreviewFileLoadedMessage, showUnsupportedFileMessage, showNotification]); + }, [storeProjectInWorkingDir, setWorking, loadEdlFile, getEdlFilePath, enableAskForImportChapters, ensureAccessToSourceDir, loadCutSegments, autoLoadTimecode, enableNativeHevc, ensureWritableOutDir, customOutDir, resetState, setCopyStreamIdsForPath, setFileFormat, outFormatLocked, setDetectedFileFormat, html5ifyAndLoadWithPreferences, showPreviewFileLoadedMessage, showUnsupportedFileMessage, showNotification]); const toggleLastCommands = useCallback(() => setLastCommandsVisible((val) => !val), []); const toggleSettings = useCallback(() => setSettingsVisible((val) => !val), []); @@ -1902,7 +1907,7 @@ function App() { const mainActions = useMemo(() => { async function exportYouTube() { if (!checkFileOpened()) return; - await openYouTubeChaptersDialog(formatYouTube(getApparentCutSegments(cutSegments))); + await openYouTubeChaptersDialog(formatYouTube(cutSegments)); } function seekReset() { @@ -2044,7 +2049,7 @@ function App() { }; return ret; - }, [toggleLoopSelectedSegments, pause, timelineToggleComfortZoom, captureSnapshot, captureSnapshotAsCoverArt, setCutStart, setCutEnd, cleanupFilesDialog, splitCurrentSegment, focusSegmentAtCursor, increaseRotation, goToTimecode, jumpCutStart, jumpCutEnd, jumpTimelineStart, jumpTimelineEnd, batchOpenSelectedFile, closeBatch, addSegment, duplicateCurrentSegment, onExportPress, extractCurrentSegmentFramesAsImages, extractSelectedSegmentsFramesAsImages, reorderSegsByStartTime, invertAllSegments, fillSegmentsGaps, combineOverlappingSegments, combineSelectedSegments, createFixedDurationSegments, createNumSegments, createRandomSegments, alignSegmentTimesToKeyframes, shuffleSegments, clearSegments, toggleSegmentsList, toggleStreamsSelector, extractAllStreams, convertFormatBatch, concatBatch, toggleCaptureFormat, toggleStripAudio, toggleStripThumbnail, askStartTimeOffset, deselectAllSegments, selectAllSegments, selectOnlyCurrentSegment, editCurrentSegmentTags, toggleCurrentSegmentSelected, invertSelectedSegments, removeSelectedSegments, tryFixInvalidDuration, shiftAllSegmentTimes, toggleMuted, copySegmentsToClipboard, handleShowStreamsSelectorClick, openFilesDialog, openDirDialog, toggleSettings, createSegmentsFromKeyframes, toggleWaveformMode, toggleShowThumbnails, toggleShowKeyframes, showIncludeExternalStreamsDialog, toggleFullscreenVideo, checkFileOpened, getApparentCutSegments, cutSegments, seekRel, keyboardSeekAccFactor, togglePlay, play, userChangePlaybackRate, keyboardNormalSeekSpeed, keyboardSeekSpeed2, keyboardSeekSpeed3, seekRelPercent, seekClosestKeyframe, shortStep, jumpSeg, setCurrentSegIndex, zoomRel, batchFileJump, removeCutSegment, currentSegIndexSafe, cutSegmentsHistory, onLabelSegment, toggleLastCommands, userHtml5ifyCurrentFile, toggleKeyframeCut, setPlaybackVolume, closeFileWithConfirm, openSendReportDialogWithState, detectBlackScenes, detectSilentScenes, detectSceneChanges]); + }, [toggleLoopSelectedSegments, pause, timelineToggleComfortZoom, captureSnapshot, captureSnapshotAsCoverArt, setCutStart, setCutEnd, cleanupFilesDialog, splitCurrentSegment, focusSegmentAtCursor, increaseRotation, goToTimecode, jumpCutStart, jumpCutEnd, jumpTimelineStart, jumpTimelineEnd, batchOpenSelectedFile, closeBatch, addSegment, duplicateCurrentSegment, onExportPress, extractCurrentSegmentFramesAsImages, extractSelectedSegmentsFramesAsImages, reorderSegsByStartTime, invertAllSegments, fillSegmentsGaps, combineOverlappingSegments, combineSelectedSegments, createFixedDurationSegments, createNumSegments, createRandomSegments, alignSegmentTimesToKeyframes, shuffleSegments, clearSegments, toggleSegmentsList, toggleStreamsSelector, extractAllStreams, convertFormatBatch, concatBatch, toggleCaptureFormat, toggleStripAudio, toggleStripThumbnail, askStartTimeOffset, deselectAllSegments, selectAllSegments, selectOnlyCurrentSegment, editCurrentSegmentTags, toggleCurrentSegmentSelected, invertSelectedSegments, removeSelectedSegments, tryFixInvalidDuration, shiftAllSegmentTimes, toggleMuted, copySegmentsToClipboard, handleShowStreamsSelectorClick, openFilesDialog, openDirDialog, toggleSettings, createSegmentsFromKeyframes, toggleWaveformMode, toggleShowThumbnails, toggleShowKeyframes, showIncludeExternalStreamsDialog, toggleFullscreenVideo, checkFileOpened, cutSegments, seekRel, keyboardSeekAccFactor, togglePlay, play, userChangePlaybackRate, keyboardNormalSeekSpeed, keyboardSeekSpeed2, keyboardSeekSpeed3, seekRelPercent, seekClosestKeyframe, shortStep, jumpSeg, setCurrentSegIndex, zoomRel, batchFileJump, removeCutSegment, currentSegIndexSafe, cutSegmentsHistory, onLabelSegment, toggleLastCommands, userHtml5ifyCurrentFile, toggleKeyframeCut, setPlaybackVolume, closeFileWithConfirm, openSendReportDialogWithState, detectBlackScenes, detectSilentScenes, detectSceneChanges]); const getKeyboardAction = useCallback((action: MainKeyboardAction) => mainActions[action], [mainActions]); @@ -2485,7 +2490,6 @@ function App() { width={rightBarWidth} currentSegIndex={currentSegIndexSafe} cutSegments={cutSegments} - getSegApparentEnd={getSegApparentEnd} inverseCutSegments={inverseCutSegments} getFrameCount={getFrameCount} formatTimecode={formatTimecode} @@ -2558,7 +2562,6 @@ function App() { goToTimecode={goToTimecode} isSegmentSelected={isSegmentSelected} darkMode={darkMode} - getSegApparentEnd={getSegApparentEnd} /> void, + cutTime: number | undefined, + setCutTime: (type: 'start' | 'end', v: number | undefined) => void, startTimeOffset: number, seekAbs: (a: number) => void, currentCutSeg: StateSegment, @@ -101,9 +100,16 @@ const CutTimeInput = memo(({ darkMode, cutTime, setCutTime, startTimeOffset, see border, borderRadius: 5, backgroundColor: 'var(--gray5)', transition: darkModeTransition, fontSize: 13, textAlign: 'center', padding: '1px 5px', marginTop: 0, marginBottom: 0, marginLeft: isStart ? 0 : 5, marginRight: isStart ? 5 : 0, boxSizing: 'border-box', fontFamily: 'inherit', width: 90, outline: 'none', }; - const trySetTime = useCallback((timeWithOffset: number) => { - const timeWithoutOffset = Math.max(timeWithOffset - startTimeOffset, 0); + const trySetTime = useCallback((timeWithOffset: number | undefined) => { try { + if (timeWithOffset == null) { // clear time + invariant(!isStart); + setCutTime('end', undefined); + setCutTimeManual(undefined); + return; + } + + const timeWithoutOffset = Math.max(timeWithOffset - startTimeOffset, 0); setCutTime(isStart ? 'start' : 'end', timeWithoutOffset); seekAbs(timeWithoutOffset); setCutTimeManual(undefined); @@ -114,28 +120,39 @@ const CutTimeInput = memo(({ darkMode, cutTime, setCutTime, startTimeOffset, see } }, [isStart, seekAbs, setCutTime, startTimeOffset]); + const isEmptyEndTime = useCallback((v: string | undefined) => !isStart && v?.trim() === '', [isStart]); + const handleSubmit = useCallback((e: FormEvent) => { e.preventDefault(); + if (isEmptyEndTime(cutTimeManual)) { + trySetTime(undefined); // clear time + return; + } + // Don't proceed if not a valid time value const timeWithOffset = cutTimeManual != null ? parseTimecode(cutTimeManual) : undefined; if (timeWithOffset === undefined) return; trySetTime(timeWithOffset); - }, [cutTimeManual, parseTimecode, trySetTime]); + }, [cutTimeManual, isEmptyEndTime, parseTimecode, trySetTime]); const parseAndSetCutTime = useCallback((text: string) => { + if (isEmptyEndTime(text)) { + trySetTime(undefined); // clear time + return; + } + // Don't proceed if not a valid time value const timeWithOffset = parseTimecode(text); if (timeWithOffset === undefined) return; trySetTime(timeWithOffset); - }, [parseTimecode, trySetTime]); + }, [isEmptyEndTime, parseTimecode, trySetTime]); function handleCutTimeInput(text: string) { - setCutTimeManual(text); - - if (isExactDurationMatch(text)) parseAndSetCutTime(text); + if (isExactDurationMatch(text) || isEmptyEndTime(text)) parseAndSetCutTime(text); + else setCutTimeManual(text); } const tryPaste = useCallback((clipboardText: string) => { @@ -164,6 +181,12 @@ const CutTimeInput = memo(({ darkMode, cutTime, setCutTime, startTimeOffset, see if (text) tryPaste(text); }, [tryPaste]); + function renderValue() { + if (isCutTimeManualSet()) return cutTimeManual; + if (cutTime == null) return ''; + return formatTimecode({ seconds: cutTime + startTimeOffset }); + } + return (
setCutTimeManual(undefined)} onContextMenu={handleContextMenu} - value={isCutTimeManualSet() - ? cutTimeManual - : formatTimecode({ seconds: cutTime + startTimeOffset })} + value={renderValue()} />
); @@ -195,7 +216,6 @@ function BottomBar({ toggleShowThumbnails, toggleWaveformMode, waveformMode, showThumbnails, outputPlaybackRate, setOutputPlaybackRate, formatTimecode, parseTimecode, playbackRate, - getSegApparentEnd, }: { zoom: number, setZoom: (fn: (z: number) => number) => void, @@ -221,7 +241,7 @@ function BottomBar({ jumpCutEnd: () => void, jumpCutStart: () => void, startTimeOffset: number, - setCutTime: (type: 'start' | 'end', v: number) => void, + setCutTime: (type: 'start' | 'end', v: number | undefined) => void, playing: boolean, shortStep: (a: number) => void, togglePlay: () => void, @@ -244,7 +264,6 @@ function BottomBar({ formatTimecode: FormatTimecode, parseTimecode: ParseTimecode, playbackRate: number, - getSegApparentEnd: GetSegApparentEnd, }) { const { t } = useTranslation(); const { getSegColor } = useSegColors(); @@ -380,7 +399,7 @@ function BottomBar({ - {!simpleMode && } + {!simpleMode && } seekClosestKeyframe(1)} /> - {!simpleMode && } + {!simpleMode && } diff --git a/src/renderer/src/SegmentList.tsx b/src/renderer/src/SegmentList.tsx index 5fe48fc8..1746854e 100644 --- a/src/renderer/src/SegmentList.tsx +++ b/src/renderer/src/SegmentList.tsx @@ -15,11 +15,12 @@ import useUserSettings from './hooks/useUserSettings'; import { saveColor, controlsBackground, primaryTextColor, darkModeTransition } from './colors'; import { useSegColors } from './contexts'; import { mySpring } from './animations'; -import { getSegApparentStart, getSegmentTags } from './segments'; +import { getSegmentTags } from './segments'; import TagEditor from './components/TagEditor'; import { ContextMenuTemplate, FormatTimecode, GetFrameCount, InverseCutSegment, SegmentTags, StateSegment } from './types'; import { UseSegments } from './hooks/useSegments'; + const buttonBaseStyle = { margin: '0 3px', borderRadius: 3, color: 'white', cursor: 'pointer', }; @@ -55,7 +56,6 @@ const Segment = memo(({ onExtractSegmentFramesAsImages, onInvertSelectedSegments, onDuplicateSegmentClick, - getSegApparentEnd, }: { seg: StateSegment | InverseCutSegment, index: number, @@ -81,10 +81,9 @@ const Segment = memo(({ jumpSegEnd: (i: number) => void, addSegment: UseSegments['addSegment'], onEditSegmentTags: (i: number) => void, - onExtractSegmentFramesAsImages: (segIds: string[]) => Promise, + onExtractSegmentFramesAsImages: (segments: Pick[]) => Promise, onInvertSelectedSegments: UseSegments['invertSelectedSegments'], onDuplicateSegmentClick: UseSegments['duplicateSegment'], - getSegApparentEnd: UseSegments['getSegApparentEnd'], }) => { const { invertCutSegments, darkMode } = useUserSettings(); const { t } = useTranslation(); @@ -132,14 +131,19 @@ const Segment = memo(({ { type: 'separator' }, { label: t('Segment tags'), click: () => onEditSegmentTags(index) }, - { label: t('Extract frames as image files'), click: () => onExtractSegmentFramesAsImages([seg.segId]) }, + ...(seg.end != null ? [{ label: t('Extract frames as image files'), click: () => onExtractSegmentFramesAsImages([seg as Pick]) }] : []), ]; }, [invertCutSegments, t, addSegment, onLabelSelectedSegments, onRemoveSelected, updateSegOrder, index, jumpSegStart, jumpSegEnd, onLabelPress, onRemovePress, onDuplicateSegmentClick, seg, onSelectSingleSegment, onSelectAllSegments, onDeselectAllSegments, onSelectSegmentsByLabel, onSelectSegmentsByExpr, onInvertSelectedSegments, onMutateSegmentsByExpr, onReorderPress, onEditSegmentTags, onExtractSegmentFramesAsImages]); useContextMenu(ref, contextMenuTemplate); - const duration = useMemo(() => getSegApparentEnd(seg) - getSegApparentStart(seg), [getSegApparentEnd, seg]); - const durationMs = duration * 1000; + const duration = useMemo(() => (seg.end == null ? undefined : seg.end - seg.start), [seg]); + + const timeStr = useMemo(() => ( + seg.end == null + ? formatTimecode({ seconds: seg.start }) + : `${formatTimecode({ seconds: seg.start })} - ${formatTimecode({ seconds: seg.end })}` + ), [formatTimecode, seg]); const isActive = !invertCutSegments && currentSegIndex === index; @@ -158,16 +162,11 @@ const Segment = memo(({ return {index + 1}; } - const timeStr = useMemo(() => `${formatTimecode({ seconds: getSegApparentStart(seg) })} - ${formatTimecode({ seconds: getSegApparentEnd(seg) })}`, [formatTimecode, seg, getSegApparentEnd]); - const onDoubleClick = useCallback(() => { if (invertCutSegments) return; jumpSegStart(index); }, [index, invertCutSegments, jumpSegStart]); - const durationMsFormatted = Math.floor(durationMs); - const frameCount = getFrameCount(duration); - const CheckIcon = selected ? FaRegCheckCircle : FaRegCircle; const onToggleSegmentSelectedClick = useCallback((e) => { @@ -196,24 +195,26 @@ const Segment = memo(({ exit={{ scaleY: 0 }} className="segment-list-entry" > -
+
{renderNumber()} - {timeStr} + {timeStr}
-
- {'name' in seg && seg.name && {seg.name}} - {Object.entries(tags).map(([name, value]) => ( - {name}:{value} - ))} -
- -
- {t('Duration')} {formatTimecode({ seconds: duration, shorten: true })} -
-
- {{ durationMsFormatted }} ms, {{ frameCount: frameCount ?? '?' }} frames -
+ {'name' in seg && seg.name && {seg.name}} + {Object.entries(tags).map(([name, value]) => ( + {name}:{value} + ))} + + {duration != null && ( + <> +
+ {t('Duration')} {formatTimecode({ seconds: duration, shorten: true })} +
+
+ {{ durationMsFormatted: Math.floor(duration * 1000) }} ms, {{ frameCount: (duration && getFrameCount(duration)) ?? '?' }} frames +
+ + )} {!invertCutSegments && (
@@ -263,7 +264,6 @@ function SegmentList({ setEditingSegmentTags, setEditingSegmentTagsSegmentIndex, onEditSegmentTags, - getSegApparentEnd, }: { width: number, formatTimecode: FormatTimecode, @@ -291,7 +291,7 @@ function SegmentList({ onSelectSegmentsByLabel: UseSegments['onSelectSegmentsByLabel'], onSelectSegmentsByExpr: UseSegments['onSelectSegmentsByExpr'], onMutateSegmentsByExpr: UseSegments['onMutateSegmentsByExpr'], - onExtractSegmentFramesAsImages: (segIds: string[]) => Promise, + onExtractSegmentFramesAsImages: (segments: Pick[]) => Promise, onLabelSelectedSegments: UseSegments['onLabelSelectedSegments'], onInvertSelectedSegments: UseSegments['invertSelectedSegments'], onDuplicateSegmentClick: UseSegments['duplicateSegment'], @@ -303,7 +303,6 @@ function SegmentList({ setEditingSegmentTags: Dispatch>, setEditingSegmentTagsSegmentIndex: Dispatch>, onEditSegmentTags: (index: number) => void, - getSegApparentEnd: UseSegments['getSegApparentEnd'], }) { const { t } = useTranslation(); const { getSegColor } = useSegColors(); @@ -351,7 +350,7 @@ function SegmentList({ const currentSegColor = getButtonColor(currentCutSeg); const segAtCursorColor = getButtonColor(segmentAtCursor); - const segmentsTotal = selectedSegments.reduce((acc, seg) => (getSegApparentEnd(seg) - getSegApparentStart(seg)) + acc, 0); + const segmentsTotal = selectedSegments.reduce((acc, seg) => (seg.end == null ? 0 : seg.end - seg.start) + acc, 0); return ( <> @@ -513,7 +512,6 @@ function SegmentList({ onLabelSelectedSegments={onLabelSelectedSegments} onInvertSelectedSegments={onInvertSelectedSegments} onDuplicateSegmentClick={onDuplicateSegmentClick} - getSegApparentEnd={getSegApparentEnd} /> ); })} diff --git a/src/renderer/src/Timeline.tsx b/src/renderer/src/Timeline.tsx index d7f01af7..ea1e425c 100644 --- a/src/renderer/src/Timeline.tsx +++ b/src/renderer/src/Timeline.tsx @@ -16,7 +16,6 @@ import styles from './Timeline.module.css'; import { timelineBackground, darkModeTransition } from './colors'; import { Frame } from './ffmpeg'; import { FormatTimecode, InverseCutSegment, RenderableWaveform, StateSegment, Thumbnail } from './types'; -import { UseSegments } from './hooks/useSegments'; type CalculateTimelinePercent = (time: number) => string | undefined; @@ -111,7 +110,6 @@ function Timeline({ goToTimecode, isSegmentSelected, darkMode, - getSegApparentEnd, } : { durationSafe: number, startTimeOffset: number, @@ -143,7 +141,6 @@ function Timeline({ goToTimecode: () => void, isSegmentSelected: (a: { segId: string }) => boolean, darkMode: boolean, - getSegApparentEnd: UseSegments['getSegApparentEnd'], }) { const { t } = useTranslation(); @@ -383,8 +380,6 @@ function Timeline({ ref={timelineWrapperRef} > {cutSegments.map((seg, i) => { - if (seg.start === 0 && seg.end === 0) return null; // No video loaded - const selected = invertCutSegments || isSegmentSelected({ segId: seg.segId }); return ( @@ -398,7 +393,6 @@ function Timeline({ invertCutSegments={invertCutSegments} formatTimecode={formatTimecode} selected={selected} - getSegApparentEnd={getSegApparentEnd} /> ); })} diff --git a/src/renderer/src/TimelineSeg.tsx b/src/renderer/src/TimelineSeg.tsx index e20a4b07..c97979d7 100644 --- a/src/renderer/src/TimelineSeg.tsx +++ b/src/renderer/src/TimelineSeg.tsx @@ -1,17 +1,76 @@ -import { memo, useMemo } from 'react'; +import { memo, useCallback, useMemo } from 'react'; import { motion, AnimatePresence, MotionStyle } from 'framer-motion'; import { FaTrashAlt } from 'react-icons/fa'; +import Color from 'color'; import { mySpring } from './animations'; import useUserSettings from './hooks/useUserSettings'; import { useSegColors } from './contexts'; import { FormatTimecode, StateSegment } from './types'; -import { getSegApparentStart } from './segments'; -import { UseSegments } from './hooks/useSegments'; +function Marker({ + seg, segNum, color, isActive, selected, onClick, getTimePercent, formatTimecode, +}: { + seg: StateSegment, + segNum: number, + color: Color, + isActive: boolean, + selected: boolean, + onClick: () => void, + getTimePercent: (a: number) => string, + formatTimecode: FormatTimecode, +}) { + const { darkMode } = useUserSettings(); + + const pinColor = darkMode ? color.saturate(0.2).lightness(40).string() : color.desaturate(0.2).lightness(50).string(); + + const title = []; + title.push(formatTimecode({ seconds: seg.start, shorten: true })); + if (seg.name) title.push(seg.name); + + const borderColor = useMemo(() => { + if (isActive) { + if (darkMode) return 'rgba(255,255,255,0.5)'; + return 'rgba(0,0,0,0.5)'; + } + return 'rgba(0,0,0,0)'; + }, [darkMode, isActive]); + + return ( + +
+
onClick()} + > + {segNum + 1} +
+
+
+ ); +} + function TimelineSeg({ - seg, duration, isActive, segNum, onSegClick, invertCutSegments, formatTimecode, selected, getSegApparentEnd, + seg, duration, isActive, segNum, onSegClick, invertCutSegments, formatTimecode, selected, } : { seg: StateSegment, duration: number, @@ -21,7 +80,6 @@ function TimelineSeg({ invertCutSegments: boolean, formatTimecode: FormatTimecode, selected: boolean, - getSegApparentEnd: UseSegments['getSegApparentEnd'], }) { const { darkMode } = useUserSettings(); const { getSegColor } = useSegColors(); @@ -30,14 +88,9 @@ function TimelineSeg({ const { name } = seg; - const cutStart = useMemo(() => getSegApparentStart(seg), [seg]); - const cutEnd = useMemo(() => getSegApparentEnd(seg), [getSegApparentEnd, seg]); - - const cutSectionWidth = `${((cutEnd - cutStart) / duration) * 100}%`; + const getTimePercent = (t: number) => `${(t / duration) * 100}%`; - const startTimePos = `${(cutStart / duration) * 100}%`; - - const markerBorder = useMemo(() => { + const vertBorder = useMemo(() => { if (!isActive) return '2px solid transparent'; return `1.5px solid ${darkMode ? segColor.desaturate(0.1).lightness(70).string() : segColor.desaturate(0.2).lightness(40).string()}`; }, [darkMode, isActive, segColor]); @@ -48,13 +101,26 @@ function TimelineSeg({ if (isActive) return darkMode ? segColor.saturate(0.2).lightness(60).alpha(0.7).string() : segColor.saturate(0.2).lightness(40).alpha(0.8).string(); return darkMode ? segColor.desaturate(0.2).lightness(50).alpha(0.7).string() : segColor.lightness(35).alpha(0.6).string(); }, [darkMode, invertCutSegments, isActive, segColor, selected]); - const markerBorderRadius = 5; + + const vertBorderRadius = 5; + + const onThisSegClick = useCallback(() => onSegClick(segNum), [onSegClick, segNum]); + + if (seg.end == null) { + if (invertCutSegments) return null; + + return ( + + ); + } + + const cutSectionWidth = `${((seg.end - seg.start) / duration) * 100}%`; const wrapperStyle: MotionStyle = { position: 'absolute', top: 0, bottom: 0, - left: startTimePos, + left: getTimePercent(seg.start), width: cutSectionWidth, display: 'flex', alignItems: 'center', @@ -64,19 +130,18 @@ function TimelineSeg({ color: 'white', overflow: 'hidden', - borderLeft: markerBorder, - borderTopLeftRadius: markerBorderRadius, - borderBottomLeftRadius: markerBorderRadius, + borderLeft: vertBorder, + borderTopLeftRadius: vertBorderRadius, + borderBottomLeftRadius: vertBorderRadius, - borderRight: markerBorder, - borderTopRightRadius: markerBorderRadius, - borderBottomRightRadius: markerBorderRadius, + borderRight: vertBorder, + borderTopRightRadius: vertBorderRadius, + borderBottomRightRadius: vertBorderRadius, }; - const onThisSegClick = () => onSegClick(segNum); - const title: string[] = []; - if (cutEnd > cutStart) title.push(`${formatTimecode({ seconds: cutEnd - cutStart, shorten: true })}`); + title.push(formatTimecode({ seconds: seg.start, shorten: true })); + if (seg.end != null) title.push(`- ${formatTimecode({ seconds: seg.end, shorten: true })}`); if (name) title.push(name); return ( diff --git a/src/renderer/src/components/ExportButton.tsx b/src/renderer/src/components/ExportButton.tsx index bc684d2f..c5684efc 100644 --- a/src/renderer/src/components/ExportButton.tsx +++ b/src/renderer/src/components/ExportButton.tsx @@ -9,7 +9,10 @@ import { SegmentToExport } from '../types'; function ExportButton({ segmentsToExport, areWeCutting, onClick, size = 1 }: { - segmentsToExport: SegmentToExport[], areWeCutting: boolean, onClick: () => void, size?: number | undefined, + segmentsToExport: SegmentToExport[], + areWeCutting: boolean, + onClick: () => void, + size?: number | undefined, }) { const CutIcon = areWeCutting ? FiScissors : FaFileExport; diff --git a/src/renderer/src/dialogs/index.tsx b/src/renderer/src/dialogs/index.tsx index 8040fd31..e18bb8d8 100644 --- a/src/renderer/src/dialogs/index.tsx +++ b/src/renderer/src/dialogs/index.tsx @@ -14,7 +14,7 @@ import { parseYouTube } from '../edlFormats'; import CopyClipboardButton from '../components/CopyClipboardButton'; import Checkbox from '../components/Checkbox'; import { isWindows, showItemInFolder } from '../util'; -import { ParseTimecode, SegmentBase } from '../types'; +import { ParseTimecode } from '../types'; import { FindKeyframeMode } from '../ffmpeg'; const remote = window.require('@electron/remote'); @@ -225,10 +225,10 @@ async function askForNumSegments() { export async function createNumSegments(fileDuration: number) { const numSegments = await askForNumSegments(); if (numSegments == null) return undefined; - const edl: SegmentBase[] = []; + const edl: { start: number, end: number }[] = []; const segDuration = fileDuration / numSegments; for (let i = 0; i < numSegments; i += 1) { - edl.push({ start: i * segDuration, end: i === numSegments - 1 ? undefined : (i + 1) * segDuration }); + edl.push({ start: i * segDuration, end: i === numSegments - 1 ? fileDuration : (i + 1) * segDuration }); } return edl; } @@ -472,10 +472,10 @@ export async function createFixedDurationSegments({ fileDuration, inputPlacehold }) { const segmentDuration = await askForSegmentDuration({ fileDuration, inputPlaceholder, parseTimecode }); if (segmentDuration == null) return undefined; - const edl: SegmentBase[] = []; + const edl: { start: number, end: number }[] = []; for (let start = 0; start < fileDuration; start += segmentDuration) { const end = start + segmentDuration; - edl.push({ start, end: end >= fileDuration ? undefined : end }); + edl.push({ start, end: end >= fileDuration ? fileDuration : end }); } return edl; } @@ -488,7 +488,7 @@ export async function createRandomSegments(fileDuration: number) { const randomInRange = (min: number, max: number) => min + Math.random() * (max - min); - const edl: SegmentBase[] = []; + const edl: { start: number, end: number }[] = []; for (let start = randomInRange(gapMin, gapMax); start < fileDuration && edl.length < maxSegments; start += randomInRange(gapMin, gapMax)) { const end = Math.min(fileDuration, start + randomInRange(durationMin, durationMax)); edl.push({ start, end }); @@ -632,6 +632,7 @@ export async function selectSegmentsByExprDialog(inputValidator: (v: string) => label: { name: i18n.t('Segment label (exact)'), code: "segment.label === 'My label'" }, regexp: { name: i18n.t('Segment label (regexp)'), code: '/^My label/.test(segment.label)' }, tag: { name: i18n.t('Segment tag value'), code: "segment.tags.myTag === 'tag value'" }, + markers: { name: i18n.t('Markers'), code: 'segment.end == null' }, }, title: i18n.t('Select segments by expression'), description: Enter a JavaScript expression which will be evaluated for each segment. Segments for which the expression evaluates to "true" will be selected. , @@ -649,6 +650,8 @@ export async function mutateSegmentsByExprDialog(inputValidator: (v: string) => // eslint-disable-next-line no-template-curly-in-string addNumToLabel: { name: i18n.t('Add number suffix to label'), code: '{ label: `${segment.label} ${segment.index + 1}` }' }, tagEven: { name: i18n.t('Add a tag to every even segment'), code: '{ tags: (segment.index + 1) % 2 === 0 ? { ...segment.tags, even: \'true\' } : segment.tags }' }, + segmentsToMarkers: { name: i18n.t('Convert segments to markers'), code: '{ end: undefined }' }, + markersToSegments: { name: i18n.t('Convert markers to segments'), code: '{ ...(segment.end == null && { end: segment.start + 5 }) }' }, }, title: i18n.t('Edit segments by expression'), description: Enter a JavaScript expression which will be evaluated for each selected segment. Returned properties will be edited. , diff --git a/src/renderer/src/edlFormats.ts b/src/renderer/src/edlFormats.ts index 17959745..98b21485 100644 --- a/src/renderer/src/edlFormats.ts +++ b/src/renderer/src/edlFormats.ts @@ -53,13 +53,13 @@ export async function parseCsv(csvStr: string, parseTimeFn: (a: string) => numbe const mapped = rows .map(([start, end, name]) => ({ - start: parseTimeFn(start), + start: parseTimeFn(start) ?? 0, end: parseTimeFn(end), name: name.trim(), })); if (!mapped.every(({ start, end }) => ( - (start === undefined || !Number.isNaN(start)) + !Number.isNaN(start) && (end === undefined || !Number.isNaN(end)) ))) { console.log(mapped); @@ -209,7 +209,7 @@ export function parseCuesheet(cuesheet: ICueSheet) { const nextTrack = tracks![i + 1]; const end = nextTrack && getTime(nextTrack); - return { name: track.title, start: getTime(track), end, tags: { performer: track.performer, title: track.title } }; + return { name: track.title, start: getTime(track) ?? 0, end, tags: { performer: track.performer, title: track.title } }; }); } diff --git a/src/renderer/src/edlStore.ts b/src/renderer/src/edlStore.ts index b6e1ff14..8b775d7d 100644 --- a/src/renderer/src/edlStore.ts +++ b/src/renderer/src/edlStore.ts @@ -1,11 +1,12 @@ import JSON5 from 'json5'; import i18n from 'i18next'; import invariant from 'tiny-invariant'; +import { ZodError } from 'zod'; import { parseSrtToSegments, formatSrt, parseCuesheet, parseXmeml, parseFcpXml, parseCsv, parseCutlist, parsePbf, parseEdl, formatCsvHuman, formatTsv, formatCsvFrames, formatCsvSeconds, parseCsvTime, getFrameValParser, parseDvAnalyzerSummaryTxt } from './edlFormats'; import { askForYouTubeInput, showOpenDialog } from './dialogs'; import { getOutPath } from './util'; -import { EdlExportType, EdlFileType, EdlImportType, GetFrameCount, SegmentBase, StateSegment } from './types'; +import { EdlExportType, EdlFileType, EdlImportType, GetFrameCount, LlcProject, llcProjectV1Schema, llcProjectV2Schema, SegmentBase, StateSegment } from './types'; const { readFile, writeFile } = window.require('fs/promises'); const cueParser = window.require('cue-parser'); @@ -84,25 +85,35 @@ export async function saveLlcProject({ savePath, filePath, cutSegments }: { cutSegments: StateSegment[], }) { const projectData = { - version: 1, + version: 2, mediaFileName: basename(filePath), cutSegments: cutSegments.map(({ start, end, name, tags }) => ({ start, end, name, tags })), }; await writeFile(savePath, JSON5.stringify(projectData, null, 2)); } -export async function loadLlcProject(path: string) { - const parsed = JSON5.parse(await readFile(path, 'utf8')) as unknown; - if (parsed == null || typeof parsed !== 'object') throw new Error('Invalid LLC file'); - let mediaFileName: string | undefined; - if ('mediaFileName' in parsed && typeof parsed.mediaFileName === 'string') { - mediaFileName = parsed.mediaFileName; +export async function loadLlcProject(path: string): Promise { + const json = JSON5.parse(await readFile(path, 'utf8')); + + // todo probably remove migration in future + try { + return llcProjectV2Schema.parse(json); + } catch (err) { + if (err instanceof ZodError) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { cutSegments, version: _ignored, ...restProject } = llcProjectV1Schema.parse(json); + console.log('Converting v1 project to v2'); + return { + ...restProject, + version: 2, + cutSegments: cutSegments.map(({ start, ...restSeg }) => ({ + ...restSeg, + start: start ?? 0, // v1 allowed undefined for "start" + })), + }; + } + throw err; } - if (!('cutSegments' in parsed) || !Array.isArray(parsed.cutSegments)) throw new Error('Invalid LLC file'); - return { - mediaFileName, - cutSegments: parsed.cutSegments as StateSegment[], // todo validate more - }; } export async function readEdlFile({ type, path, fps }: { type: EdlFileType, path: string, fps: number | undefined }) { diff --git a/src/renderer/src/hooks/useSegments.ts b/src/renderer/src/hooks/useSegments.ts index b9d3e880..1a8fa7dc 100644 --- a/src/renderer/src/hooks/useSegments.ts +++ b/src/renderer/src/hooks/useSegments.ts @@ -10,7 +10,7 @@ import { handleError, shuffleArray } from '../util'; import { errorToast } from '../swal'; import { showParametersDialog } from '../dialogs/parameters'; import { createNumSegments as createNumSegmentsDialog, createFixedDurationSegments as createFixedDurationSegmentsDialog, createRandomSegments as createRandomSegmentsDialog, labelSegmentDialog, askForShiftSegments, askForAlignSegments, selectSegmentsByLabelDialog, selectSegmentsByExprDialog, mutateSegmentsByExprDialog } from '../dialogs'; -import { createSegment, findSegmentsAtCursor, sortSegments, invertSegments, combineOverlappingSegments as combineOverlappingSegments2, combineSelectedSegments as combineSelectedSegments2, isDurationValid, getSegApparentStart, getSegApparentEnd as getSegApparentEnd2, addSegmentColorIndex } from '../segments'; +import { createSegment, sortSegments, invertSegments, combineOverlappingSegments as combineOverlappingSegments2, combineSelectedSegments as combineSelectedSegments2, isDurationValid, addSegmentColorIndex, filterNonMarkers } from '../segments'; import { parameters as allFfmpegParameters, FfmpegDialog } from '../ffmpegParameters'; import { maxSegmentsAllowed } from '../util/constants'; import { ParseTimecode, SegmentBase, segmentTagsSchema, SegmentToExport, StateSegment, UpdateSegAtIndex } from '../types'; @@ -21,7 +21,7 @@ import { FFprobeStream } from '../../../../ffprobe'; const { ffmpeg: { blackDetect, silenceDetect } } = window.require('@electron/remote').require('./index.js'); -function useSegments({ filePath, workingRef, setWorking, setProgress, videoStream, duration, getRelevantTime, maxLabelLength, checkFileOpened, invertCutSegments, segmentsToChaptersOnly, timecodePlaceholder, parseTimecode, appendFfmpegCommandLog }: { +function useSegments({ filePath, workingRef, setWorking, setProgress, videoStream, duration, getRelevantTime, maxLabelLength, checkFileOpened, invertCutSegments, segmentsToChaptersOnly, timecodePlaceholder, parseTimecode, appendFfmpegCommandLog, durationSafe }: { filePath?: string | undefined, workingRef: MutableRefObject, setWorking: (w: { text: string, abortController?: AbortController } | undefined) => void, @@ -36,6 +36,7 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea timecodePlaceholder: string, parseTimecode: ParseTimecode, appendFfmpegCommandLog: (args: string[]) => void, + durationSafe: number, }) { // Segment related state const segCounterRef = useRef(0); @@ -84,13 +85,11 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea const shuffleSegments = useCallback(() => setCutSegments((oldSegments) => shuffleArray(oldSegments)), [setCutSegments]); + // todo combine with safeSetCutSegments? const loadCutSegments = useCallback((edl: SegmentBase[], append: boolean | undefined = 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) + row.start >= 0 + && (row.end == null || row.start < row.end) )); if (validEdl.length === 0) throw new Error(i18n.t('No valid segments found')); @@ -132,28 +131,18 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea } }, [filePath, workingRef, setWorking, setProgress, appendFfmpegCommandLog, loadCutSegments]); - const getSegApparentEnd = useCallback((seg: SegmentBase) => getSegApparentEnd2(seg, duration), [duration]); - - // must clone tags because scope is mutable (editable by expression) const getScopeSegment = useCallback((seg: Pick, index: number): ScopeSegment => { - const start = getSegApparentStart(seg); - const end = getSegApparentEnd(seg); - - return { index, label: seg.name, start, end, duration: end - start, tags: { ...seg.tags } }; - }, [getSegApparentEnd]); - - const getApparentCutSegments = useCallback((segments: T[]) => segments.map((cutSegment) => ({ - ...cutSegment, - start: getSegApparentStart(cutSegment), - end: getSegApparentEnd(cutSegment), - })), [getSegApparentEnd]); + 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) => getSegApparentStart(cutSegment) >= getSegApparentEnd(cutSegment)), [cutSegments, getSegApparentEnd]); + 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(() => { const ret = cutSegments[currentSegIndexSafe]; - if (ret == null) throw new Error('currentCutSeg was nullish, this shouldn\'t happen'); + invariant(ret != null, 'currentCutSeg was nullish, this shouldn\'t happen'); return ret; }, [currentSegIndexSafe, cutSegments]); @@ -166,6 +155,7 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea }, [ffmpegParameters]); const detectBlackScenes = useCallback(async () => { + const { start, end = durationSafe } = currentCutSeg; 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; @@ -173,10 +163,11 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea 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 () => blackDetect({ filePath, filterOptions, boundingMode: mode === '1', onProgress: setProgress, from: getSegApparentStart(currentCutSeg), to: getSegApparentEnd(currentCutSeg) }) }); - }, [getFfmpegParameters, setFfmpegParametersForDialog, filePath, detectSegments, setProgress, currentCutSeg, getSegApparentEnd]); + await detectSegments({ name: 'blackScenes', workingText: i18n.t('Detecting black scenes'), errorText: i18n.t('Failed to detect black scenes'), fn: async () => blackDetect({ filePath, filterOptions, boundingMode: mode === '1', onProgress: setProgress, from: start, to: end }) }); + }, [durationSafe, currentCutSeg, getFfmpegParameters, setFfmpegParametersForDialog, filePath, detectSegments, setProgress]); const detectSilentScenes = useCallback(async () => { + const { start, end = durationSafe } = currentCutSeg; 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; @@ -184,10 +175,11 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea 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 () => silenceDetect({ filePath, filterOptions, boundingMode: mode === '1', onProgress: setProgress, from: getSegApparentStart(currentCutSeg), to: getSegApparentEnd(currentCutSeg) }) }); - }, [currentCutSeg, detectSegments, filePath, getFfmpegParameters, getSegApparentEnd, setFfmpegParametersForDialog, setProgress]); + await detectSegments({ name: 'silentScenes', workingText: i18n.t('Detecting silent scenes'), errorText: i18n.t('Failed to detect silent scenes'), fn: async () => silenceDetect({ filePath, filterOptions, boundingMode: mode === '1', onProgress: setProgress, from: start, to: end }) }); + }, [currentCutSeg, detectSegments, durationSafe, filePath, getFfmpegParameters, setFfmpegParametersForDialog, setProgress]); const detectSceneChanges = useCallback(async () => { + const { start, end = durationSafe } = currentCutSeg; const dialogType = 'sceneChange'; const parameters = await showParametersDialog({ title: i18n.t('Enter parameters'), dialogType, parameters: getFfmpegParameters(dialogType) }); if (parameters == null) return; @@ -196,16 +188,17 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea // 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 () => ffmpegDetectSceneChanges({ filePath, minChange, onProgress: setProgress, from: getSegApparentStart(currentCutSeg), to: getSegApparentEnd(currentCutSeg) }) }); - }, [currentCutSeg, detectSegments, filePath, getFfmpegParameters, getSegApparentEnd, setFfmpegParametersForDialog, setProgress]); + await detectSegments({ name: 'sceneChanges', workingText: i18n.t('Detecting scene changes'), errorText: i18n.t('Failed to detect scene changes'), fn: async () => ffmpegDetectSceneChanges({ filePath, minChange, onProgress: setProgress, from: start, to: end }) }); + }, [currentCutSeg, detectSegments, durationSafe, filePath, getFfmpegParameters, setFfmpegParametersForDialog, setProgress]); const createSegmentsFromKeyframes = useCallback(async () => { + const { start, end = durationSafe } = currentCutSeg; if (!videoStream) return; invariant(filePath != null); - const keyframes = (await readFrames({ filePath, from: getSegApparentStart(currentCutSeg), to: getSegApparentEnd(currentCutSeg), streamIndex: videoStream.index })).filter((frame) => frame.keyframe); + 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(newSegments, true); - }, [currentCutSeg, filePath, getSegApparentEnd, loadCutSegments, videoStream]); + }, [currentCutSeg, durationSafe, filePath, loadCutSegments, videoStream]); const removeSegments = useCallback((removeSegmentIds: string[]) => { setCutSegments((existingSegments) => { @@ -229,16 +222,20 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea const inverseCutSegments = useMemo(() => { if (haveInvalidSegs || !isDurationValid(duration)) return []; - return invertSegments(sortSegments(getApparentCutSegments(cutSegments)), true, true, duration).map(({ segId, start, end }) => { - // this is workaround to please TS - if (segId == null || start == null || end == null) throw new Error(`Encountered inverted segment with nullish value ${JSON.stringify({ segId, start, end })}`); + + // exclude segments that don't have a length (markers) + const sortedSegments = sortSegments(filterNonMarkers(cutSegments)); + + return invertSegments(sortedSegments, true, true, duration).map(({ segId, end, ...rest }) => { + // in order to please TS: + invariant(segId != null && end != null); return { segId, - start, end, + ...rest, }; }); - }, [cutSegments, duration, getApparentCutSegments, haveInvalidSegs]); + }, [cutSegments, duration, haveInvalidSegs]); const invertAllSegments = useCallback(() => { if (inverseCutSegments.length === 0) { @@ -260,11 +257,11 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea }, [createIndexedSegment, inverseCutSegments, setCutSegments]); const combineOverlappingSegments = useCallback(() => { - setCutSegments((existingSegments) => combineOverlappingSegments2(existingSegments, getSegApparentEnd)); - }, [getSegApparentEnd, setCutSegments]); + setCutSegments((existingSegments) => combineOverlappingSegments2(existingSegments)); + }, [setCutSegments]); const combineSelectedSegments = useCallback(() => { - setCutSegments((existingSegments) => combineSelectedSegments2(existingSegments, getSegApparentEnd2, isSegmentSelected)); + setCutSegments((existingSegments) => combineSelectedSegments2({ existingSegments, isSegmentSelected })); }, [isSegmentSelected, setCutSegments]); const updateSegAtIndex = useCallback((index, newProps) => { @@ -276,21 +273,26 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea setCutSegments(cutSegmentsNew); }, [setCutSegments, cutSegments]); - const setCutTime = useCallback((type: 'start' | 'end', time: number) => { + const setCutTime = useCallback((type: 'start' | 'end', time: number | undefined) => { if (!isDurationValid(duration)) return; - const currentSeg = currentCutSeg; - if (type === 'start' && time >= getSegApparentEnd(currentSeg)) { - throw new Error('Start time must precede end time'); + if (type === 'start') { + invariant(time != null); + if (currentCutSeg.end != null && time >= currentCutSeg.end) { + throw new Error('Start time must precede end time'); + } + updateSegAtIndex(currentSegIndexSafe, { start: Math.min(Math.max(time, 0), duration) }); } - if (type === 'end' && time <= getSegApparentStart(currentSeg)) { - throw new Error('Start time must precede end time'); + if (type === 'end') { + if (time != null && time <= currentCutSeg.start) { + throw new Error('Start time must precede end time'); + } + updateSegAtIndex(currentSegIndexSafe, { end: time != null ? Math.min(Math.max(time, 0), duration) : undefined }); } - updateSegAtIndex(currentSegIndexSafe, { [type]: Math.min(Math.max(time, 0), duration) }); - }, [currentSegIndexSafe, getSegApparentEnd, currentCutSeg, duration, updateSegAtIndex]); + }, [currentSegIndexSafe, currentCutSeg, duration, updateSegAtIndex]); const clampValue = useCallback((val: number | undefined) => { - if (val == null) return undefined; + if (val == null || Number.isNaN(val)) return undefined; const clamped = Math.max(val, 0); if (duration == null) return clamped; return Math.min(clamped, duration); @@ -299,22 +301,22 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea const safeSetCutSegments = useCallback((newSegments: StateSegment[]) => { const safeNewSegments = newSegments.map(({ start, end, ...rest }) => ({ ...rest, - start: clampValue(start), + start: clampValue(start) ?? 0, end: clampValue(end), - })).filter((segment) => segment.end == null || segment.start == null || segment.end > segment.start); + })).filter((segment) => segment.end == null || segment.end > segment.start); setCutSegments(safeNewSegments.length > 0 ? safeNewSegments : createInitialCutSegments()); }, [clampValue, createInitialCutSegments, setCutSegments]); - const modifySelectedSegmentTimes = useCallback(async (transformSegment: (s: T) => Promise | T, concurrency = 5) => { - const newSegments = await pMap(getApparentCutSegments(cutSegments), async (segment) => { + const modifySelectedSegmentTimes = useCallback(async (transformSegment: (s: T) => Promise | T, concurrency = 5) => { + const newSegments = await pMap(cutSegments, async (segment) => { if (!isSegmentSelected(segment)) return segment; // pass thru non-selected segments return transformSegment(segment); }, { concurrency }); safeSetCutSegments(newSegments); - }, [cutSegments, getApparentCutSegments, isSegmentSelected, safeSetCutSegments]); + }, [cutSegments, isSegmentSelected, safeSetCutSegments]); const shiftAllSegmentTimes = useCallback(async () => { const shift = await askForShiftSegments({ inputPlaceholder: timecodePlaceholder, parseTimecode }); @@ -324,7 +326,7 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea await modifySelectedSegmentTimes((segment) => { const newSegment = { ...segment }; shiftKeys.forEach((key) => { - newSegment[key] += shiftAmount; + if (newSegment[key] != null) newSegment[key] += shiftAmount; }); return newSegment; }); @@ -342,10 +344,12 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea const align = async (key: 'start' | 'end') => { const time = newSegment[key]; - if (filePath == null) throw new Error(); - const keyframe = await findKeyframeNearTime({ filePath, streamIndex: videoStream.index, time, mode }); - if (keyframe == null) throw new Error(`Cannot find any keyframe within 60 seconds of frame ${time}`); - newSegment[key] = keyframe; + invariant(filePath != null); + if (time != null) { + const keyframe = await findKeyframeNearTime({ filePath, streamIndex: videoStream.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'); @@ -376,7 +380,7 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea }, [cutSegments, setCutSegments, currentCutSeg, setCurrentSegIndex]); const reorderSegsByStartTime = useCallback(() => { - setCutSegments(sortBy(cutSegments, getSegApparentStart)); + setCutSegments(sortBy(cutSegments, (seg) => seg.start)); }, [cutSegments, setCutSegments]); const addSegment = useCallback(() => { @@ -473,16 +477,28 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea if (value != null) updateSegAtIndex(index, { name: value }); }, [cutSegments, updateSegAtIndex, maxLabelLength]); + const findSegmentsAtCursor = useCallback((currentTime: number) => ( + cutSegments.flatMap((segment, index) => { + if ( + (segment.start == null || 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(getApparentCutSegments(cutSegments), relevantTime); + const [firstSegmentAtCursorIndex] = findSegmentsAtCursor(relevantTime); if (firstSegmentAtCursorIndex == null) return; setCurrentSegIndex(firstSegmentAtCursorIndex); - }, [cutSegments, getApparentCutSegments, getRelevantTime]); + }, [findSegmentsAtCursor, getRelevantTime]); const splitCurrentSegment = useCallback(() => { const relevantTime = getRelevantTime(); - const segmentsAtCursorIndexes = findSegmentsAtCursor(getApparentCutSegments(cutSegments), relevantTime); + const segmentsAtCursorIndexes = findSegmentsAtCursor(relevantTime); const firstSegmentAtCursorIndex = segmentsAtCursorIndexes[0]; @@ -502,7 +518,7 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea const newSegments = [...cutSegments]; newSegments.splice(firstSegmentAtCursorIndex, 1, firstPart, secondPart); setCutSegments(newSegments); - }, [createIndexedSegment, cutSegments, getApparentCutSegments, getRelevantTime, setCutSegments]); + }, [createIndexedSegment, cutSegments, findSegmentsAtCursor, getRelevantTime, setCutSegments]); const createNumSegments = useCallback(async () => { if (!checkFileOpened() || !isDurationValid(duration)) return; @@ -582,7 +598,7 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea ret.start = response.start; } if ('end' in response) { - if (typeof response.end !== 'number') throw new Error(i18n.t('"{{property}}" must be a number', { property: 'end' })); + if (!(typeof response.end === 'number' || response.end === undefined)) throw new Error(i18n.t('"{{property}}" must be a number', { property: 'end' })); ret.end = response.end; } if ('tags' in response) { @@ -616,8 +632,9 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea }, [cutSegments, getScopeSegment, isSegmentSelected, safeSetCutSegments]); const onLabelSelectedSegments = useCallback(async () => { - if (selectedSegmentsRaw.length === 0) return; - const { name } = selectedSegmentsRaw[0]!; + const firstSelectedSegment = selectedSegmentsRaw[0]; + if (firstSelectedSegment == null) return; + const { name } = firstSelectedSegment; const value = await labelSegmentDialog({ currentName: name, maxLength: maxLabelLength }); if (value == null) return; setCutSegments((existingSegments) => existingSegments.map((existingSegment) => { @@ -629,17 +646,27 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea // Guaranteed to have at least one segment (if user has selected none to export (selectedSegments empty), it makes no sense so select all instead.) const selectedSegments = useMemo(() => (selectedSegmentsRaw.length > 0 ? selectedSegmentsRaw : cutSegments), [cutSegments, selectedSegmentsRaw]); - // For invertCutSegments we do not support filtering (selecting) segments - const selectedSegmentsOrInverse = useMemo[]>(() => (invertCutSegments ? inverseCutSegments : selectedSegments), [inverseCutSegments, invertCutSegments, selectedSegments]); - const nonFilteredSegmentsOrInverse = useMemo(() => (invertCutSegments ? inverseCutSegments : cutSegments), [invertCutSegments, inverseCutSegments, cutSegments]); + const selectedSegmentsOrInverse = useMemo<{ start: number, end: number }[]>(() => { + // For invertCutSegments we do not support filtering (selecting) segments + if (invertCutSegments) return inverseCutSegments; + + // exclude markers (segments without end) + return filterNonMarkers(selectedSegments); + }, [inverseCutSegments, invertCutSegments, selectedSegments]); + + // non filtered includes also non selected + const nonFilteredSegmentsOrInverse = useMemo(() => (invertCutSegments + ? inverseCutSegments + : filterNonMarkers(cutSegments) + ), [invertCutSegments, inverseCutSegments, cutSegments]); const segmentsToExport = useMemo(() => { // 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 a single segment with no cuts (full timeline) - if (segmentsToChaptersOnly) return [{ start: 0, end: getSegApparentEnd({}) }]; - - return getApparentCutSegments(selectedSegmentsOrInverse); - }, [segmentsToChaptersOnly, getSegApparentEnd, getApparentCutSegments, selectedSegmentsOrInverse]); + if (segmentsToChaptersOnly) return [{ start: 0, end: durationSafe }]; + if (selectedSegmentsOrInverse.length > 0) return selectedSegmentsOrInverse; + return [{ start: 0, end: durationSafe }]; // e.g. only markers + }, [segmentsToChaptersOnly, durationSafe, selectedSegmentsOrInverse]); const removeSelectedSegments = useCallback(() => removeSegments(selectedSegmentsRaw.map((seg) => seg.segId)), [removeSegments, selectedSegmentsRaw]); @@ -681,8 +708,6 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea createNumSegments, createFixedDurationSegments, createRandomSegments, - getApparentCutSegments, - getSegApparentEnd, haveInvalidSegs, currentSegIndexSafe, currentCutSeg, @@ -712,11 +737,10 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea selectOnlySegment, setCutTime, updateSegAtIndex, + findSegmentsAtCursor, }; } -export type GetSegApparentEnd = (seg: SegmentBase) => number; - export type UseSegments = ReturnType; export default useSegments; diff --git a/src/renderer/src/hooks/useSegmentsAutoSave.ts b/src/renderer/src/hooks/useSegmentsAutoSave.ts index 4244cc7b..514bc553 100644 --- a/src/renderer/src/hooks/useSegmentsAutoSave.ts +++ b/src/renderer/src/hooks/useSegmentsAutoSave.ts @@ -19,11 +19,8 @@ export default ({ autoSaveProjectFile, storeProjectInWorkingDir, filePath, custo cutSegments: StateSegment[], }) => { const projectSuffix = 'proj.llc'; - const oldProjectSuffix = 'llc-edl.csv'; // New LLC format can be stored along with input file or in working dir (customOutDir) const getEdlFilePath = useCallback((fp?: string, cod?: string) => getSuffixedOutPath({ customOutDir: cod, filePath: fp, nameSuffix: projectSuffix }), []); - // Old versions of LosslessCut used CSV files and stored them always in customOutDir: - const getEdlFilePathOld = useCallback((fp: string | undefined, cod?: string | undefined) => getSuffixedOutPath({ customOutDir: cod, filePath: fp, nameSuffix: oldProjectSuffix }), []); const getProjectFileSavePath = useCallback((storeProjectInWorkingDirIn: boolean) => getEdlFilePath(filePath, storeProjectInWorkingDirIn ? customOutDir : undefined), [getEdlFilePath, filePath, customOutDir]); const projectFileSavePath = useMemo(() => getProjectFileSavePath(storeProjectInWorkingDir), [getProjectFileSavePath, storeProjectInWorkingDir]); @@ -66,7 +63,6 @@ export default ({ autoSaveProjectFile, storeProjectInWorkingDir, filePath, custo return { getEdlFilePath, - getEdlFilePathOld, projectFileSavePath, getProjectFileSavePath, }; diff --git a/src/renderer/src/segments.test.ts b/src/renderer/src/segments.test.ts index b2b40924..0411fdd6 100644 --- a/src/renderer/src/segments.test.ts +++ b/src/renderer/src/segments.test.ts @@ -1,6 +1,6 @@ import { test, it, expect } from 'vitest'; -import { convertSegmentsToChapters, partitionIntoOverlappingRanges, getSegApparentEnd, formatSegNum } from './segments'; +import { convertSegmentsToChapters, partitionIntoOverlappingRanges, formatSegNum } from './segments'; it('converts segments to chapters with gaps', () => { expect(convertSegmentsToChapters([ @@ -105,9 +105,7 @@ it('detects overlapping segments, undefined end', () => { expect(partitionIntoOverlappingRanges([ { start: 1, end: undefined }, { start: 1.5, end: undefined }, - ], undefined, (seg) => getSegApparentEnd(seg, 2))).toEqual([ - [{ start: 1, end: undefined }, { start: 1.5, end: undefined }], - ]); + ])).toEqual([]); }); test('formatSegNum', () => { diff --git a/src/renderer/src/segments.ts b/src/renderer/src/segments.ts index c35b8b05..c291edcd 100644 --- a/src/renderer/src/segments.ts +++ b/src/renderer/src/segments.ts @@ -10,7 +10,7 @@ import { PlaybackMode, SegmentBase, SegmentTags, StateSegment } from './types'; export const isDurationValid = (duration?: number): duration is number => duration != null && Number.isFinite(duration) && duration > 0; export const createSegment = (props?: { start?: number | undefined, end?: number | undefined, name?: string | undefined, tags?: unknown | undefined }): Omit => ({ - start: props?.start, + start: props?.start ?? 0, end: props?.end, name: props?.name || '', segId: nanoid(), @@ -27,20 +27,6 @@ export const addSegmentColorIndex = (segment: Omit[]) => cs.map((seg) => ({ start: seg.start, end: seg.end, @@ -48,14 +34,6 @@ export const getCleanCutSegments = (cs: Pick { - if ((segment.start == null || segment.start <= currentTime) && (segment.end == null || segment.end >= currentTime)) indexes.push(index); - }); - return indexes.reverse(); // if we are on multiple, select the last. That way, auto play selected segments won't go on repeat on the same segment -} - // in the past we had non-string tags export const getSegmentTags = (segment: { tags?: SegmentTags | undefined }) => ( Object.fromEntries(Object.entries(segment.tags || {}).flatMap(([tag, value]) => (value != null ? [[tag, String(value)]] : []))) @@ -64,14 +42,7 @@ export const getSegmentTags = (segment: { tags?: SegmentTags | undefined }) => ( export const sortSegments = (segments: T[]) => sortBy(segments, 'start'); // https://stackoverflow.com/a/30472982/6519037 -export function partitionIntoOverlappingRanges( - array: T[], - getSegmentStart = getSegApparentStart, - getSegmentEnd = (seg: T) => { - if (seg.end == null) throw new Error('Unknown end of segment'); - return seg.end; - }, -) { +export function partitionIntoOverlappingRanges(array: T[]) { const [firstItem] = array; if (firstItem == null) throw new Error('No segments'); @@ -79,6 +50,8 @@ export function partitionIntoOverlappingRanges( [firstItem], ]; + const getSegmentEnd = (s: T) => s.end ?? s.start; // assume markers have a length of 0; + const getMaxEnd = (array2: T[]) => { // note: this also mutates array2 array2.sort((a, b) => { @@ -92,8 +65,8 @@ export function partitionIntoOverlappingRanges( for (let i = 1, g = 0; i < array.length; i += 1) { const item = array[i]!; - const start = getSegmentStart(item); - const prevStart = getSegmentStart(array[i - 1]!); + const { start } = item; + const prevStart = array[i - 1]!.start; if (start == null || prevStart == null) throw new Error(); if (start >= prevStart && start < getMaxEnd(ret[g]!)) { ret[g]!.push(item); @@ -103,11 +76,11 @@ export function partitionIntoOverlappingRanges( } } - return ret.filter((group) => group.length > 1).map((group) => sortBy(group, (seg) => getSegmentStart(seg))); + return ret.filter((group) => group.length > 1).map((group) => sortBy(group, (seg) => seg.start)); } -export function combineOverlappingSegments(existingSegments: T[], getSegApparentEnd2: (seg: SegmentBase) => number) { - const partitionedSegments = partitionIntoOverlappingRanges(existingSegments, getSegApparentStart, getSegApparentEnd2); +export function combineOverlappingSegments(existingSegments: T[]) { + const partitionedSegments = partitionIntoOverlappingRanges(existingSegments); return existingSegments.flatMap((existingSegment) => { const partOfPartition = partitionedSegments.find((partition) => partition.includes(existingSegment)); @@ -129,27 +102,30 @@ export function combineOverlappingSegments(existingSegmen }); } -export function combineSelectedSegments(existingSegments: StateSegment[], getSegApparentEnd2: (seg: StateSegment) => number, isSegmentSelected: (seg: StateSegment) => boolean) { +export function combineSelectedSegments({ existingSegments, isSegmentSelected }: { existingSegments: StateSegment[], isSegmentSelected: (seg: StateSegment) => boolean }) { const selectedSegments = existingSegments.filter((segment) => isSegmentSelected(segment)); - const firstSegment = minBy(selectedSegments, (seg) => getSegApparentStart(seg)); - const lastSegment = maxBy(selectedSegments, (seg) => getSegApparentEnd2(seg)); + const firstSegment = minBy(selectedSegments, (seg) => seg.start); + const lastSegment = maxBy(selectedSegments, (seg) => seg.end ?? seg.start); return existingSegments.flatMap((existingSegment) => { + invariant(lastSegment != null); if (existingSegment === firstSegment) { return [{ ...firstSegment, start: firstSegment.start, - end: lastSegment!.end, + end: lastSegment.end ?? lastSegment.start, // for markers use their start }]; } - if (isSegmentSelected(existingSegment)) return []; // remove other selected segments + if (isSegmentSelected(existingSegment)) { + return []; // remove other selected segments + } return [existingSegment]; }); } -export function hasAnySegmentOverlap(sortedSegments: SegmentBase[]) { +export function hasAnySegmentOverlap(sortedSegments: { start: number, end: number }[]) { if (sortedSegments.length === 0) return false; const overlappingGroups = partitionIntoOverlappingRanges(sortedSegments); @@ -157,12 +133,12 @@ export function hasAnySegmentOverlap(sortedSegments: SegmentBase[]) { } // eslint-disable-next-line space-before-function-paren -export function invertSegments(sortedCutSegments: (SegmentBase & { segId?: string | undefined })[], includeFirstSegment: boolean, includeLastSegment: boolean, duration?: number) { +export function invertSegments(sortedCutSegments: ({ start: number, end: number, segId?: string | undefined })[], includeFirstSegment: boolean, includeLastSegment: boolean, duration?: number) { if (sortedCutSegments.length === 0) return []; if (hasAnySegmentOverlap(sortedCutSegments)) return []; - const ret: typeof sortedCutSegments = []; + const ret: { start: number, end?: number | undefined, segId?: string | undefined }[] = []; if (includeFirstSegment) { const firstSeg = sortedCutSegments[0]!; @@ -189,7 +165,7 @@ export function invertSegments(sortedCutSegments: (SegmentBase & { segId?: strin if (includeLastSegment) { const lastSeg = sortedCutSegments.at(-1)!; if (duration == null || (lastSeg.end != null && lastSeg.end < duration)) { - const inverted: typeof sortedCutSegments[number] = { + const inverted: typeof ret[number] = { start: lastSeg.end, end: duration, }; @@ -272,3 +248,8 @@ export function formatSegNum(segIndex: number, numSegments: number, minLength = const numDigits = getNumDigits(numSegments); return `${segIndex + 1}`.padStart(Math.max(numDigits, minLength), '0'); } + +export const filterNonMarkers = (segments: T[]) => segments.flatMap(({ end, ...rest }) => (end != null ? [{ + ...rest, + end, +}] : [])); diff --git a/src/renderer/src/types.ts b/src/renderer/src/types.ts index ea47d3e5..c4eebb44 100644 --- a/src/renderer/src/types.ts +++ b/src/renderer/src/types.ts @@ -22,8 +22,33 @@ export type SegmentTags = z.infer export type EditingSegmentTags = Record +// todo remove some time in the future +export const llcProjectV1Schema = z.object({ + version: z.literal(1), + mediaFileName: z.string().optional(), + cutSegments: z.object({ + start: z.number().optional(), + end: z.number().optional(), + name: z.string(), + tags: segmentTagsSchema.optional(), + }).array(), +}); + +export const llcProjectV2Schema = z.object({ + version: z.literal(2), + mediaFileName: z.string().optional(), + cutSegments: z.object({ + start: z.number(), + end: z.number().optional(), + name: z.string(), + tags: segmentTagsSchema.optional(), + }).array(), +}); + +export type LlcProject = z.infer + export interface SegmentBase { - start?: number | undefined, + start: number, end?: number | undefined, name?: string | undefined, } diff --git a/types.ts b/types.ts index 94398460..7fac2b50 100644 --- a/types.ts +++ b/types.ts @@ -134,7 +134,7 @@ export interface ScopeSegment { index: number, label: string, start: number, - end: number, + end?: number | undefined, duration: number, tags: Record, }