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
pull/2350/head
Mikael Finstad 1 year ago
parent 250217ffe1
commit 250218bf5f
No known key found for this signature in database
GPG Key ID: 25AB36E3E81CBC26

@ -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',

@ -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<StateSegment>();
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<ReactEventHandler<HTMLVideoElement>>((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}
/>
<BottomBar
@ -2588,7 +2591,6 @@ function App() {
jumpTimelineEnd={jumpTimelineEnd}
startTimeOffset={startTimeOffset}
setCutTime={setCutTime}
getSegApparentEnd={getSegApparentEnd}
playing={playing}
shortStep={shortStep}
seekClosestKeyframe={seekClosestKeyframe}

@ -6,6 +6,7 @@ import { IoIosCamera, IoMdKey, IoMdSpeedometer } from 'react-icons/io';
import { FaYinYang, FaTrashAlt, FaStepBackward, FaStepForward, FaCaretLeft, FaCaretRight, FaPause, FaPlay, FaImages, FaKey, FaSun } from 'react-icons/fa';
import { GiSoundWaves } from 'react-icons/gi';
// import useTraceUpdate from 'use-trace-update';
import invariant from 'tiny-invariant';
import { primaryTextColor, primaryColor, darkModeTransition } from './colors';
import SegmentCutpointButton from './components/SegmentCutpointButton';
@ -25,8 +26,6 @@ import useUserSettings from './hooks/useUserSettings';
import { askForPlaybackRate } from './dialogs';
import { FormatTimecode, ParseTimecode, SegmentColorIndex, SegmentToExport, StateSegment } from './types';
import { WaveformMode } from '../../../types';
import { GetSegApparentEnd } from './hooks/useSegments';
import { getSegApparentStart } from './segments';
const { clipboard } = window.require('electron');
@ -71,8 +70,8 @@ const InvertCutModeButton = memo(({ invertCutSegments, setInvertCutSegments }: {
// eslint-disable-next-line react/display-name
const CutTimeInput = memo(({ darkMode, cutTime, setCutTime, startTimeOffset, seekAbs, currentCutSeg, isStart, formatTimecode, parseTimecode }: {
darkMode: boolean,
cutTime: number,
setCutTime: (type: 'start' | 'end', v: number) => 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<HTMLFormElement>) => {
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 (
<form onSubmit={handleSubmit}>
<input
@ -174,9 +197,7 @@ const CutTimeInput = memo(({ darkMode, cutTime, setCutTime, startTimeOffset, see
onPaste={handleCutTimePaste}
onBlur={() => setCutTimeManual(undefined)}
onContextMenu={handleContextMenu}
value={isCutTimeManualSet()
? cutTimeManual
: formatTimecode({ seconds: cutTime + startTimeOffset })}
value={renderValue()}
/>
</form>
);
@ -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({
<SetCutpointButton currentCutSeg={currentCutSeg} side="start" onClick={setCutStart} title={t('Start current segment at current time')} style={{ marginRight: 5 }} />
{!simpleMode && <CutTimeInput darkMode={darkMode} currentCutSeg={currentCutSeg} startTimeOffset={startTimeOffset} seekAbs={seekAbs} cutTime={getSegApparentStart(currentCutSeg)} setCutTime={setCutTime} isStart formatTimecode={formatTimecode} parseTimecode={parseTimecode} />}
{!simpleMode && <CutTimeInput darkMode={darkMode} currentCutSeg={currentCutSeg} startTimeOffset={startTimeOffset} seekAbs={seekAbs} cutTime={currentCutSeg.start} setCutTime={setCutTime} isStart formatTimecode={formatTimecode} parseTimecode={parseTimecode} />}
<IoMdKey
size={25}
@ -425,7 +444,7 @@ function BottomBar({
onClick={() => seekClosestKeyframe(1)}
/>
{!simpleMode && <CutTimeInput darkMode={darkMode} currentCutSeg={currentCutSeg} startTimeOffset={startTimeOffset} seekAbs={seekAbs} cutTime={getSegApparentEnd(currentCutSeg)} setCutTime={setCutTime} formatTimecode={formatTimecode} parseTimecode={parseTimecode} />}
{!simpleMode && <CutTimeInput darkMode={darkMode} currentCutSeg={currentCutSeg} startTimeOffset={startTimeOffset} seekAbs={seekAbs} cutTime={currentCutSeg.end} setCutTime={setCutTime} formatTimecode={formatTimecode} parseTimecode={parseTimecode} />}
<SetCutpointButton currentCutSeg={currentCutSeg} side="end" onClick={setCutEnd} title={t('End current segment at current time')} style={{ marginLeft: 5 }} />

@ -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<void>,
onExtractSegmentFramesAsImages: (segments: Pick<InverseCutSegment, 'start' | 'end'>[]) => Promise<void>,
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<InverseCutSegment, 'start' | 'end'>]) }] : []),
];
}, [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 <b style={{ cursor: 'grab', color: 'white', padding: '0 4px', marginRight: 3, marginLeft: -3, background: color.string(), border: `1px solid ${isActive ? borderColor.string() : 'transparent'}`, borderRadius: 10, fontSize: 12 }}>{index + 1}</b>;
}
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<MouseEventHandler>((e) => {
@ -196,24 +195,26 @@ const Segment = memo(({
exit={{ scaleY: 0 }}
className="segment-list-entry"
>
<div className="segment-handle" style={{ cursor, color: 'var(--gray12)', marginBottom: 3, display: 'flex', alignItems: 'center', height: 16 }}>
<div className="segment-handle" style={{ cursor, color: 'var(--gray12)', marginBottom: duration != null ? 3 : undefined, display: 'flex', alignItems: 'center', height: 16 }}>
{renderNumber()}
<span style={{ cursor, fontSize: Math.min(310 / timeStr.length, 14), whiteSpace: 'nowrap' }}>{timeStr}</span>
<span style={{ cursor, fontSize: Math.min(310 / timeStr.length, 12), whiteSpace: 'nowrap' }}>{timeStr}</span>
</div>
<div style={{ fontSize: 12 }}>
{'name' in seg && seg.name && <span style={{ color: primaryTextColor, marginRight: '.3em' }}>{seg.name}</span>}
{Object.entries(tags).map(([name, value]) => (
<span style={{ fontSize: 11, backgroundColor: 'var(--gray5)', color: 'var(--gray12)', borderRadius: '.4em', padding: '0 .2em', marginRight: '.1em' }} key={name}>{name}:<b>{value}</b></span>
))}
</div>
<div style={{ fontSize: 13 }}>
{t('Duration')} {formatTimecode({ seconds: duration, shorten: true })}
</div>
<div style={{ fontSize: 12 }}>
<Trans>{{ durationMsFormatted }} ms, {{ frameCount: frameCount ?? '?' }} frames</Trans>
</div>
{'name' in seg && seg.name && <span style={{ fontSize: 12, color: primaryTextColor, marginRight: '.3em' }}>{seg.name}</span>}
{Object.entries(tags).map(([name, value]) => (
<span style={{ fontSize: 11, backgroundColor: 'var(--gray5)', color: 'var(--gray12)', borderRadius: '.4em', padding: '0 .2em', marginRight: '.1em' }} key={name}>{name}:<b>{value}</b></span>
))}
{duration != null && (
<>
<div style={{ fontSize: 13 }}>
{t('Duration')} {formatTimecode({ seconds: duration, shorten: true })}
</div>
<div style={{ fontSize: 12 }}>
<Trans>{{ durationMsFormatted: Math.floor(duration * 1000) }} ms, {{ frameCount: (duration && getFrameCount(duration)) ?? '?' }} frames</Trans>
</div>
</>
)}
{!invertCutSegments && (
<div style={{ position: 'absolute', right: 3, bottom: 3 }}>
@ -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<void>,
onExtractSegmentFramesAsImages: (segments: Pick<InverseCutSegment, 'start' | 'end'>[]) => Promise<void>,
onLabelSelectedSegments: UseSegments['onLabelSelectedSegments'],
onInvertSelectedSegments: UseSegments['invertSelectedSegments'],
onDuplicateSegmentClick: UseSegments['duplicateSegment'],
@ -303,7 +303,6 @@ function SegmentList({
setEditingSegmentTags: Dispatch<SetStateAction<SegmentTags | undefined>>,
setEditingSegmentTagsSegmentIndex: Dispatch<SetStateAction<number | undefined>>,
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}
/>
);
})}

@ -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}
/>
);
})}

@ -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 (
<motion.div
style={{
position: 'absolute',
top: 0,
bottom: 0,
left: getTimePercent(seg.start),
width: 2,
marginLeft: -1,
overflow: 'visible',
backgroundColor: 'var(--gray12)',
}}
layout
transition={mySpring}
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: selected ? 1 : 0.5, scale: 1 }}
exit={{ opacity: 0, scale: 0 }}
title={title.join(' ')}
>
<div style={{ borderRadius: '50%', backgroundColor: pinColor, width: 14, height: 14, marginLeft: -7, flexShrink: 0, textAlign: 'center', border: `1px solid ${borderColor}` }}>
<div
style={{ fontSize: 10, minWidth: 0, letterSpacing: '-.1em', color: 'white' }}
role="button"
onClick={() => onClick()}
>
{segNum + 1}
</div>
</div>
</motion.div>
);
}
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 (
<Marker seg={seg} segNum={segNum} color={segColor} selected={selected} isActive={isActive} onClick={onThisSegClick} getTimePercent={getTimePercent} formatTimecode={formatTimecode} />
);
}
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 (

@ -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;

@ -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: <Trans>Enter a JavaScript expression which will be evaluated for each segment. Segments for which the expression evaluates to &quot;true&quot; will be selected. <button type="button" className="link-button" onClick={() => shell.openExternal('https://github.com/mifi/lossless-cut/blob/master/expressions.md')}>View available syntax.</button></Trans>,
@ -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: <Trans>Enter a JavaScript expression which will be evaluated for each selected segment. Returned properties will be edited. <button type="button" className="link-button" onClick={() => shell.openExternal('https://github.com/mifi/lossless-cut/blob/master/expressions.md')}>View available syntax.</button></Trans>,

@ -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 } };
});
}

@ -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<LlcProject> {
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 }) {

@ -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<boolean>,
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<StateSegment, 'name' | 'start' | 'end' | 'tags'>, 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(<T extends SegmentBase>(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<UpdateSegAtIndex>((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: <T extends { start: number, end: number }>(s: T) => Promise<T> | T, concurrency = 5) => {
const newSegments = await pMap(getApparentCutSegments(cutSegments), async (segment) => {
const modifySelectedSegmentTimes = useCallback(async (transformSegment: <T extends SegmentBase>(s: T) => Promise<T> | 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<Pick<StateSegment, 'start' | 'end'>[]>(() => (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<SegmentToExport[]>(() => {
// segmentsToChaptersOnly is a special mode where all segments will be simply written out as chapters to one file: https://github.com/mifi/lossless-cut/issues/993#issuecomment-1037927595
// Chapters export mode: Emulate 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<typeof useSegments>;
export default useSegments;

@ -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,
};

@ -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', () => {

@ -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<StateSegment, 'segColorIndex'> => ({
start: props?.start,
start: props?.start ?? 0,
end: props?.end,
name: props?.name || '',
segId: nanoid(),
@ -27,20 +27,6 @@ export const addSegmentColorIndex = (segment: Omit<StateSegment, 'segColorIndex'
segColorIndex,
});
// Because segments could have undefined start / end
// meaning extend from start of timeline or to end whatever time end has (video duration)
export function getSegApparentStart(seg: SegmentBase) {
const time = seg.start;
return time !== undefined ? time : 0;
}
export function getSegApparentEnd(seg: SegmentBase, duration: number | undefined) {
const time = seg.end;
if (time !== undefined) return time;
if (isDurationValid(duration)) return duration;
return 0; // Haven't gotten duration yet - what do to ¯\_(ツ)_/¯
}
export const getCleanCutSegments = (cs: Pick<StateSegment, 'start' | 'end' | 'name' | 'tags'>[]) => cs.map((seg) => ({
start: seg.start,
end: seg.end,
@ -48,14 +34,6 @@ export const getCleanCutSegments = (cs: Pick<StateSegment, 'start' | 'end' | 'na
tags: seg.tags,
}));
export function findSegmentsAtCursor(segments: SegmentBase[], currentTime: number) {
const indexes: number[] = [];
segments.forEach((segment, index) => {
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 = <T extends { start: number }>(segments: T[]) => sortBy(segments, 'start');
// https://stackoverflow.com/a/30472982/6519037
export function partitionIntoOverlappingRanges<T extends SegmentBase>(
array: T[],
getSegmentStart = getSegApparentStart,
getSegmentEnd = (seg: T) => {
if (seg.end == null) throw new Error('Unknown end of segment');
return seg.end;
},
) {
export function partitionIntoOverlappingRanges<T extends SegmentBase>(array: T[]) {
const [firstItem] = array;
if (firstItem == null) throw new Error('No segments');
@ -79,6 +50,8 @@ export function partitionIntoOverlappingRanges<T extends SegmentBase>(
[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<T extends SegmentBase>(
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<T extends SegmentBase>(
}
}
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<T extends SegmentBase>(existingSegments: T[], getSegApparentEnd2: (seg: SegmentBase) => number) {
const partitionedSegments = partitionIntoOverlappingRanges(existingSegments, getSegApparentStart, getSegApparentEnd2);
export function combineOverlappingSegments<T extends SegmentBase>(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<T extends SegmentBase>(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 = <T extends { end?: number | undefined }>(segments: T[]) => segments.flatMap(({ end, ...rest }) => (end != null ? [{
...rest,
end,
}] : []));

@ -22,8 +22,33 @@ export type SegmentTags = z.infer<typeof segmentTagsSchema>
export type EditingSegmentTags = Record<string, SegmentTags>
// 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<typeof llcProjectV2Schema>
export interface SegmentBase {
start?: number | undefined,
start: number,
end?: number | undefined,
name?: string | undefined,
}

@ -134,7 +134,7 @@ export interface ScopeSegment {
index: number,
label: string,
start: number,
end: number,
end?: number | undefined,
duration: number,
tags: Record<string, string>,
}

Loading…
Cancel
Save