create initial full segment

when loading file (mimics previous behaviour)
also allow no segments on timeline
pull/2233/head
Mikael Finstad 1 year ago
parent 63f9969eda
commit cbab2f6c55
No known key found for this signature in database
GPG Key ID: 25AB36E3E81CBC26

@ -126,6 +126,7 @@ function App() {
const [progress, setProgress] = useState<number>();
const [startTimeOffset, setStartTimeOffset] = useState(0);
const [filePath, setFilePath] = useState<string>();
const [fileDuration, setFileDuration] = useState<number>();
const [externalFilesMeta, setExternalFilesMeta] = useState<FilesMeta>({});
const [customTagsByFile, setCustomTagsByFile] = useState<CustomTagsByFile>({});
const [paramsByStreamId, setParamsByStreamId] = useState<ParamsByStreamId>(new Map());
@ -178,7 +179,7 @@ function App() {
} = allUserSettings;
const { working, setWorking, workingRef, abortWorking } = useLoading();
const { videoRef, videoContainerRef, playbackRate, setPlaybackRate, outputPlaybackRate, setOutputPlaybackRate, commandedTime, seekAbs, playingRef, getRelevantTime, setPlaying, onSeeked, relevantTime, onStartPlaying, setCommandedTime, setCompatPlayerEventId, compatPlayerEventId, setOutputPlaybackRateState, commandedTimeRef, onStopPlaying, onVideoAbort, fileDuration, setFileDuration, playerTime, setPlayerTime, playbackModeRef, onDurationChange, playing, play, pause, seekRel } = useVideo({ filePath });
const { videoRef, videoContainerRef, playbackRate, setPlaybackRate, outputPlaybackRate, setOutputPlaybackRate, commandedTime, seekAbs, playingRef, getRelevantTime, setPlaying, onSeeked, relevantTime, onStartPlaying, setCommandedTime, setCompatPlayerEventId, compatPlayerEventId, setOutputPlaybackRateState, commandedTimeRef, onStopPlaying, onVideoAbort, playerTime, setPlayerTime, playbackModeRef, playing, play, pause, seekRel } = useVideo({ filePath });
const { timecodePlaceholder, formatTimecode, formatTimeAndFrames, parseTimecode, getFrameCount } = useTimecode({ detectedFps, timecodeFormat });
const { loadSubtitle, subtitlesByStreamId, setSubtitlesByStreamId } = useSubtitles();
@ -336,13 +337,24 @@ function App() {
}, [isFileOpened]);
const {
cutSegments, cutSegmentsHistory, createSegmentsFromKeyframes, shuffleSegments, detectBlackScenes, detectSilentScenes, detectSceneChanges, removeCutSegment, invertAllSegments, fillSegmentsGaps, combineOverlappingSegments, combineSelectedSegments, shiftAllSegmentTimes, alignSegmentTimesToKeyframes, updateSegOrder, updateSegOrders, reorderSegsByStartTime, addSegment, setCutStart, setCutEnd, labelSegment, splitCurrentSegment, focusSegmentAtCursor, createNumSegments, createFixedDurationSegments, createFixedByteSizedSegments, createRandomSegments, haveInvalidSegs, currentSegIndexSafe, currentCutSeg, inverseCutSegments, clearSegments, loadCutSegments, isSegmentSelected, setCutTime, setCurrentSegIndex, labelSelectedSegments, deselectAllSegments, selectAllSegments, selectOnlyCurrentSegment, toggleCurrentSegmentSelected, invertSelectedSegments, removeSelectedSegments, setDeselectedSegmentIds, selectSegmentsByLabel, selectSegmentsByExpr, selectAllMarkers, mutateSegmentsByExpr, toggleSegmentSelected, selectOnlySegment, selectedSegments, selectedSegmentsOrInverse, nonFilteredSegmentsOrInverse, segmentsToExport, duplicateCurrentSegment, duplicateSegment, updateSegAtIndex, findSegmentsAtCursor,
cutSegments, cutSegmentsHistory, createSegmentsFromKeyframes, shuffleSegments, detectBlackScenes, detectSilentScenes, detectSceneChanges, removeCutSegment, invertAllSegments, fillSegmentsGaps, combineOverlappingSegments, combineSelectedSegments, shiftAllSegmentTimes, alignSegmentTimesToKeyframes, updateSegOrder, updateSegOrders, reorderSegsByStartTime, addSegment, setCutStart, setCutEnd, labelSegment, splitCurrentSegment, focusSegmentAtCursor, createNumSegments, createFixedDurationSegments, createFixedByteSizedSegments, createRandomSegments, haveInvalidSegs, currentSegIndexSafe, currentCutSeg, inverseCutSegments, clearSegments, clearSegColorCounter, loadCutSegments, isSegmentSelected, setCutTime, setCurrentSegIndex, labelSelectedSegments, deselectAllSegments, selectAllSegments, selectOnlyCurrentSegment, toggleCurrentSegmentSelected, invertSelectedSegments, removeSelectedSegments, setDeselectedSegmentIds, selectSegmentsByLabel, selectSegmentsByExpr, selectAllMarkers, mutateSegmentsByExpr, toggleSegmentSelected, selectOnlySegment, selectedSegments, selectedSegmentsOrInverse, nonFilteredSegmentsOrInverse, segmentsToExport, duplicateCurrentSegment, duplicateSegment, updateSegAtIndex, findSegmentsAtCursor, maybeCreateFullLengthSegment,
} = useSegments({ filePath, workingRef, setWorking, setProgress, videoStream: activeVideoStream, fileDuration, getRelevantTime, maxLabelLength, checkFileOpened, invertCutSegments, segmentsToChaptersOnly, timecodePlaceholder, parseTimecode, appendFfmpegCommandLog, fileDurationNonZero, mainFileMeta });
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 onDurationChange = useCallback<ReactEventHandler<HTMLVideoElement>>((e) => {
// Some files report duration infinity first, then proper duration later
// Sometimes after seeking to end of file, duration might change
const { duration: durationNew } = e.currentTarget;
console.log('onDurationChange', durationNew);
if (isDurationValid(durationNew)) {
setFileDuration(durationNew);
maybeCreateFullLengthSegment(durationNew);
}
}, [maybeCreateFullLengthSegment]);
const segmentAtCursor = useMemo(() => {
const segmentsAtCursorIndexes = findSegmentsAtCursor(commandedTime);
const firstSegmentAtCursorIndex = segmentsAtCursorIndexes[0];
@ -372,7 +384,10 @@ function App() {
seekAbs(nextFrame / fps);
}, [detectedFps, seekAbs, videoRef]);
const jumpSegStart = useCallback((index: number) => seekAbs(cutSegments[index]!.start), [cutSegments, seekAbs]);
const jumpSegStart = useCallback((index: number) => {
const seg = cutSegments[index];
if (seg != null) seekAbs(seg.start);
}, [cutSegments, seekAbs]);
const jumpSegEnd = useCallback((index: number) => {
const seg = cutSegments[index];
if (seg?.end != null) seekAbs(seg.end);
@ -571,7 +586,6 @@ function App() {
setCompatPlayerEventId(0);
setFileDuration(undefined);
cutSegmentsHistory.go(0);
clearSegments();
setFileFormat(undefined);
setDetectedFileFormat(undefined);
setRotation(360);
@ -598,7 +612,7 @@ function App() {
setExportConfirmVisible(false);
setOutputPlaybackRateState(1);
setCurrentFileExportCount(0);
}, [videoRef, setCommandedTime, setPlaybackRate, setPlaying, playingRef, playbackModeRef, setCompatPlayerEventId, setFileDuration, cutSegmentsHistory, clearSegments, setFileFormat, setDetectedFileFormat, setCopyStreamIdsByFile, setThumbnails, setDeselectedSegmentIds, setSubtitlesByStreamId, setOutputPlaybackRateState]);
}, [videoRef, setCommandedTime, setPlaybackRate, setPlaying, playingRef, playbackModeRef, setCompatPlayerEventId, setFileDuration, cutSegmentsHistory, setFileFormat, setDetectedFileFormat, setCopyStreamIdsByFile, setThumbnails, setDeselectedSegmentIds, setSubtitlesByStreamId, setOutputPlaybackRateState]);
const showUnsupportedFileMessage = useCallback(() => {
@ -739,11 +753,12 @@ function App() {
// if no segment at cursor, and looping playback mode, continue looping
if (playbackModeRef.current === 'loop-selected-segments') {
const firstSelectedSegment = selectedSegments[0];
invariant(firstSelectedSegment != null);
if (firstSelectedSegment != null) {
const index = cutSegments.indexOf(firstSelectedSegment);
if (index >= 0) setCurrentSegIndex(index);
seekAbs(firstSelectedSegment.start);
} else {
}
} else if (currentCutSeg != null) {
// for all other playback modes, seek to start of current segment
seekAbs(currentCutSeg.start);
}
@ -793,7 +808,8 @@ function App() {
if (askBeforeClose && !window.confirm(i18n.t('Are you sure you want to close the current file?'))) return;
resetState();
}, [isFileOpened, workingRef, askBeforeClose, resetState]);
clearSegments();
}, [isFileOpened, workingRef, askBeforeClose, resetState, clearSegments]);
const closeBatch = useCallback(() => {
// eslint-disable-next-line no-alert
@ -946,6 +962,7 @@ function App() {
// close the file
resetState();
clearSegments();
}
await withErrorHandling(async () => {
@ -960,7 +977,7 @@ function App() {
await deleteFiles({ paths: pathsToDelete, deleteIfTrashFails: cleanupChoices2.deleteIfTrashFails, signal: abortController.signal });
}, (err) => i18n.t('Unable to delete file: {{message}}', { message: err instanceof Error ? err.message : String(err) }));
}, [batchListRemoveFile, filePath, previewFilePath, projectFileSavePath, resetState, setWorking]);
}, [batchListRemoveFile, clearSegments, filePath, previewFilePath, projectFileSavePath, resetState, setWorking]);
const askForCleanupChoices = useCallback(async () => {
const trashResponse = await showCleanupFilesDialog(cleanupChoices);
@ -1248,7 +1265,9 @@ function App() {
}
}, [filePath, detectedFps, workingRef, getFrameCount, setWorking, hideAllNotifications, captureFramesRange, customOutDir, captureFormat, captureFrameQuality, captureFrameFileNameFormat, showOsNotification, outputDir]);
const extractCurrentSegmentFramesAsImages = useCallback(() => extractSegmentsFramesAsImages([currentCutSeg]), [currentCutSeg, extractSegmentsFramesAsImages]);
const extractCurrentSegmentFramesAsImages = useCallback(() => {
if (currentCutSeg != null) extractSegmentsFramesAsImages([currentCutSeg]);
}, [currentCutSeg, extractSegmentsFramesAsImages]);
const extractSelectedSegmentsFramesAsImages = useCallback(() => extractSegmentsFramesAsImages(selectedSegments), [extractSegmentsFramesAsImages, selectedSegments]);
@ -1372,11 +1391,12 @@ function App() {
const needsAutoHtml5ify = !existingHtml5FriendlyFile && !willPlayerProperlyHandleVideo({ streams: fileMeta.streams, hevcPlaybackSupported }) && validDuration;
// BEGIN STATE UPDATES:
console.log('loadMedia', { filePath: fp, customOutDir: cod, projectPath });
// BEGIN STATE UPDATES:
resetState();
clearSegColorCounter();
if (existingHtml5FriendlyFile) {
console.log('Found existing html5 friendly file', existingHtml5FriendlyFile.path);
@ -1440,7 +1460,7 @@ function App() {
resetState();
throw err;
}
}, [storeProjectInWorkingDir, setWorking, loadEdlFile, getEdlFilePath, 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, clearSegColorCounter, setCopyStreamIdsForPath, setDetectedFileFormat, outFormatLocked, html5ifyAndLoadWithPreferences, setFileFormat, showNotification, showPreviewFileLoadedMessage, showUnsupportedFileMessage]);
const toggleLastCommands = useCallback(() => setLastCommandsVisible((val) => !val), []);
const toggleSettings = useCallback(() => setSettingsVisible((val) => !val), []);

@ -74,7 +74,7 @@ const CutTimeInput = memo(({ darkMode, cutTime, setCutTime, startTimeOffset, see
setCutTime: (type: 'start' | 'end', v: number | undefined) => void,
startTimeOffset: number,
seekAbs: (a: number) => void,
currentCutSeg: StateSegment,
currentCutSeg: StateSegment | undefined,
isStart?: boolean,
formatTimecode: FormatTimecode,
parseTimecode: ParseTimecode,
@ -87,7 +87,7 @@ const CutTimeInput = memo(({ darkMode, cutTime, setCutTime, startTimeOffset, see
// Clear manual overrides if upstream cut time has changed
useEffect(() => {
setCutTimeManual(undefined);
}, [setCutTimeManual, currentCutSeg.start, currentCutSeg.end]);
}, [setCutTimeManual, currentCutSeg?.start, currentCutSeg?.end]);
const isCutTimeManualSet = () => cutTimeManual !== undefined;
@ -232,7 +232,7 @@ function BottomBar({
seekAbs: (a: number) => void,
currentSegIndexSafe: number,
cutSegments: StateSegment[],
currentCutSeg: StateSegment,
currentCutSeg: StateSegment | undefined,
setCutStart: () => void,
setCutEnd: () => void,
setCurrentSegIndex: Dispatch<SetStateAction<number>>,
@ -270,8 +270,15 @@ function BottomBar({
// ok this is a bit over-engineered but what the hell!
const loopSelectedSegmentsButtonStyle = useMemo(() => {
// cannot have less than 1 gradient element:
const selectedSegmentsSafe = (selectedSegments.length > 1 ? selectedSegments : [selectedSegments[0]!, selectedSegments[0]!]).slice(0, 10);
// need at least 2 gradient elements:
const selectedSegmentsSafe = (
selectedSegments.length >= 2
? selectedSegments
: [
selectedSegments[0] ?? { segColorIndex: 0 },
selectedSegments[1] ?? { segColorIndex: 1 },
]
).slice(0, 10);
const gradientColors = selectedSegmentsSafe.map((seg, i) => {
const segColor = getSegColorRaw(seg);
@ -399,7 +406,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={currentCutSeg.start} 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}
@ -444,7 +451,7 @@ function BottomBar({
onClick={() => seekClosestKeyframe(1)}
/>
{!simpleMode && <CutTimeInput darkMode={darkMode} currentCutSeg={currentCutSeg} startTimeOffset={startTimeOffset} seekAbs={seekAbs} cutTime={currentCutSeg.end} 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 }} />

@ -11,7 +11,7 @@ const electron = window.require('electron');
function NoFileLoaded({ mifiLink, currentCutSeg, onClick, darkMode }: {
mifiLink: unknown,
currentCutSeg: StateSegment,
currentCutSeg: StateSegment | undefined,
onClick: () => void,
darkMode?: boolean,
}) {

@ -138,7 +138,10 @@ function WheelModifier({ text, wheelText, modifier }: { text: string, wheelText:
const KeyboardShortcuts = memo(({
keyBindings, setKeyBindings, resetKeyBindings, currentCutSeg,
}: {
keyBindings: KeyBinding[], setKeyBindings: Dispatch<SetStateAction<KeyBinding[]>>, resetKeyBindings: () => void, currentCutSeg: StateSegment,
keyBindings: KeyBinding[],
setKeyBindings: Dispatch<SetStateAction<KeyBinding[]>>,
resetKeyBindings: () => void,
currentCutSeg: StateSegment | undefined,
}) => {
const { t } = useTranslation();
@ -835,7 +838,12 @@ const KeyboardShortcuts = memo(({
function KeyboardShortcutsDialog({
isShown, onHide, keyBindings, setKeyBindings, resetKeyBindings, currentCutSeg,
}: {
isShown: boolean, onHide: () => void, keyBindings: KeyBinding[], setKeyBindings: Dispatch<SetStateAction<KeyBinding[]>>, resetKeyBindings: () => void, currentCutSeg: StateSegment,
isShown: boolean,
onHide: () => void,
keyBindings: KeyBinding[],
setKeyBindings: Dispatch<SetStateAction<KeyBinding[]>>,
resetKeyBindings: () => void,
currentCutSeg: StateSegment | undefined,
}) {
const { t } = useTranslation();

@ -6,7 +6,7 @@ import useUserSettings from '../hooks/useUserSettings';
import { SegmentBase, SegmentColorIndex } from '../types';
const SegmentCutpointButton = ({ currentCutSeg, side, Icon, onClick, title, style }: {
currentCutSeg: SegmentBase & SegmentColorIndex,
currentCutSeg: (SegmentBase & SegmentColorIndex) | undefined,
side: 'start' | 'end',
Icon: typeof FaStepForward,
onClick?: (() => void) | undefined,

@ -7,7 +7,7 @@ import { SegmentBase, SegmentColorIndex } from '../types';
// constant side because we are mirroring
const SetCutpointButton = ({ currentCutSeg, side, title, onClick, style }: {
currentCutSeg: SegmentBase & SegmentColorIndex,
currentCutSeg: (SegmentBase & SegmentColorIndex) | undefined,
side: 'start' | 'end',
title?: string,
onClick?: () => void,

@ -599,7 +599,7 @@ export async function labelSegmentDialog({ currentName, maxLength }: { currentNa
return value;
}
export async function selectSegmentsByLabelDialog(currentName: string) {
export async function selectSegmentsByLabelDialog(currentName?: string | undefined) {
const { value } = await Swal.fire({
showCancelButton: true,
title: i18n.t('Select segments by label'),

@ -40,18 +40,15 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea
mainFileMeta: { formatData: FFprobeFormat } | undefined,
}) {
// Segment related state
const segCounterRef = useRef(0);
const segColorCounterRef = useRef(0);
const createIndexedSegment = useCallback(({ segment, incrementCount }: { segment?: Parameters<typeof createSegment>[0], incrementCount?: boolean } = {}) => {
if (incrementCount) segCounterRef.current += 1;
const ret = addSegmentColorIndex(createSegment(segment), segCounterRef.current);
return ret;
if (incrementCount) segColorCounterRef.current += 1;
return addSegmentColorIndex(createSegment(segment), segColorCounterRef.current);
}, []);
const createInitialCutSegments = useCallback(() => [createIndexedSegment()], [createIndexedSegment]);
const [cutSegments, setCutSegments, cutSegmentsHistory] = useStateWithHistory(
createInitialCutSegments(),
const [cutSegments, setCutSegments, cutSegmentsHistory] = useStateWithHistory<StateSegment[], StateSegment[]>(
[],
100,
);
@ -74,17 +71,41 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea
const isSegmentSelected = useCallback(({ segId }: { segId: string }) => !deselectedSegmentIds[segId], [deselectedSegmentIds]);
const clearSegCounter = useCallback(() => {
const clearSegColorCounter = useCallback(() => {
// eslint-disable-next-line no-param-reassign
segCounterRef.current = 0;
}, [segCounterRef]);
segColorCounterRef.current = 0;
}, [segColorCounterRef]);
const clampValue = useCallback((val: number | undefined) => {
if (val == null || Number.isNaN(val)) return undefined;
const clamped = Math.max(val, 0);
if (fileDuration == null) return clamped;
return Math.min(clamped, fileDuration);
}, [fileDuration]);
const safeSetCutSegments = useCallback((newSegmentsOrFn: StateSegment[] | ((a: StateSegment[]) => StateSegment[])) => {
// delete "initial" after modifying segments
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const map = (newSegments: StateSegment[]) => newSegments.map(({ start, end, initial: _ignored, ...rest }) => ({
...rest,
start: clampValue(start) ?? 0,
end: clampValue(end),
})).filter((segment) => segment.end == null || segment.end > segment.start);
if (typeof newSegmentsOrFn === 'function') {
setCutSegments((existing) => map(newSegmentsOrFn(existing)));
return;
}
setCutSegments(map(newSegmentsOrFn));
}, [clampValue, setCutSegments]);
const clearSegments = useCallback(() => {
clearSegCounter();
setCutSegments(createInitialCutSegments());
}, [clearSegCounter, createInitialCutSegments, setCutSegments]);
clearSegColorCounter();
safeSetCutSegments([]);
}, [clearSegColorCounter, safeSetCutSegments]);
const shuffleSegments = useCallback(() => setCutSegments((oldSegments) => shuffleArray(oldSegments)), [setCutSegments]);
const shuffleSegments = useCallback(() => safeSetCutSegments((oldSegments) => shuffleArray(oldSegments)), [safeSetCutSegments]);
// todo combine with safeSetCutSegments?
const loadCutSegments = useCallback((edl: SegmentBase[], append: boolean | undefined = false) => {
@ -95,17 +116,17 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea
if (validEdl.length === 0) throw new Error(i18n.t('No valid segments found'));
if (!append) clearSegCounter();
if (validEdl.length > maxSegmentsAllowed) throw new Error(i18n.t('Tried to create too many segments (max {{maxSegmentsAllowed}}.)', { maxSegmentsAllowed }));
setCutSegments((existingSegments) => {
if (!append) clearSegColorCounter();
safeSetCutSegments((existingSegments) => {
const needToAppend = append && !isInitialSegment(existingSegments);
let newSegments = validEdl.map((segment, i) => createIndexedSegment({ segment, incrementCount: needToAppend || i > 0 }));
if (needToAppend) newSegments = [...existingSegments, ...newSegments];
return newSegments;
});
}, [clearSegCounter, createIndexedSegment, setCutSegments]);
}, [clearSegColorCounter, createIndexedSegment, safeSetCutSegments]);
const detectSegments = useCallback(async ({ name, workingText, errorText, fn }: {
name: string,
@ -142,11 +163,7 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea
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];
invariant(ret != null, 'currentCutSeg was nullish, this shouldn\'t happen');
return ret;
}, [currentSegIndexSafe, cutSegments]);
const currentCutSeg = useMemo(() => cutSegments[currentSegIndexSafe], [currentSegIndexSafe, cutSegments]);
const selectedSegmentsRaw = useMemo(() => cutSegments.filter((segment) => isSegmentSelected(segment)), [cutSegments, isSegmentSelected]);
@ -157,7 +174,7 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea
}, [ffmpegParameters]);
const detectBlackScenes = useCallback(async () => {
const { start, end = fileDurationNonZero } = currentCutSeg;
const { start = 0, end = fileDurationNonZero } = 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;
@ -169,7 +186,7 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea
}, [fileDurationNonZero, currentCutSeg, getFfmpegParameters, setFfmpegParametersForDialog, filePath, detectSegments, setProgress]);
const detectSilentScenes = useCallback(async () => {
const { start, end = fileDurationNonZero } = currentCutSeg;
const { start = 0, end = fileDurationNonZero } = 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;
@ -181,7 +198,7 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea
}, [currentCutSeg, detectSegments, fileDurationNonZero, filePath, getFfmpegParameters, setFfmpegParametersForDialog, setProgress]);
const detectSceneChanges = useCallback(async () => {
const { start, end = fileDurationNonZero } = currentCutSeg;
const { start = 0, end = fileDurationNonZero } = currentCutSeg ?? {};
const dialogType = 'sceneChange';
const parameters = await showParametersDialog({ title: i18n.t('Enter parameters'), dialogType, parameters: getFfmpegParameters(dialogType) });
if (parameters == null) return;
@ -194,7 +211,7 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea
}, [currentCutSeg, detectSegments, fileDurationNonZero, filePath, getFfmpegParameters, setFfmpegParametersForDialog, setProgress]);
const createSegmentsFromKeyframes = useCallback(async () => {
const { start, end = fileDurationNonZero } = currentCutSeg;
const { start = 0, end = fileDurationNonZero } = currentCutSeg ?? {};
if (!videoStream) return;
invariant(filePath != null);
const keyframes = (await readFrames({ filePath, from: start, to: end, streamIndex: videoStream.index })).filter((frame) => frame.keyframe);
@ -203,23 +220,21 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea
}, [currentCutSeg, fileDurationNonZero, filePath, loadCutSegments, videoStream]);
const removeSegments = useCallback((removeSegmentIds: string[]) => {
setCutSegments((existingSegments) => {
if (isInitialSegment(existingSegments)) {
return existingSegments; // We are at initial segment, nothing more we can do (it cannot be removed)
}
safeSetCutSegments((existingSegments) => {
const newSegments = existingSegments.filter((seg) => !removeSegmentIds.includes(seg.segId));
if (newSegments.length === 0) {
// when removing the last segments, we start over
clearSegCounter();
return createInitialCutSegments();
clearSegColorCounter();
return [];
}
return newSegments;
});
}, [clearSegCounter, createInitialCutSegments, setCutSegments]);
}, [clearSegColorCounter, safeSetCutSegments]);
const removeCutSegment = useCallback((index: number) => {
removeSegments([cutSegments[index]!.segId]);
const seg = cutSegments[index];
if (seg == null) return;
removeSegments([seg.segId]);
}, [cutSegments, removeSegments]);
const inverseCutSegments = useMemo(() => {
@ -239,7 +254,7 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea
});
}, [cutSegments, fileDuration, haveInvalidSegs]);
// Guaranteed to have at least one segment (if user has selected none to export (selectedSegments empty), it makes no sense so select all instead.)
// If user has selected none, default to all instead.
const selectedSegments = useMemo(() => (selectedSegmentsRaw.length > 0 ? selectedSegmentsRaw : cutSegments), [cutSegments, selectedSegmentsRaw]);
const invertAllSegments = useCallback(() => {
@ -254,8 +269,8 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea
}
// preserve segColorIndex (which represent colors) when inverting
const newInverseCutSegments = inverseSegmentsAndMarkers.map((inverseSegment, index) => addSegmentColorIndex(createSegment(inverseSegment), index));
setCutSegments(newInverseCutSegments);
}, [fileDuration, selectedSegments, setCutSegments]);
safeSetCutSegments(newInverseCutSegments);
}, [fileDuration, selectedSegments, safeSetCutSegments]);
const fillSegmentsGaps = useCallback(() => {
// treat markers as 0 length
@ -268,16 +283,16 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea
return;
}
const newInverseCutSegments = inverseSegmentsAndMarkers.map((segment) => createIndexedSegment({ segment, incrementCount: true }));
setCutSegments((existing) => ([...existing, ...newInverseCutSegments]));
}, [createIndexedSegment, fileDuration, selectedSegments, setCutSegments]);
safeSetCutSegments((existing) => ([...existing, ...newInverseCutSegments]));
}, [createIndexedSegment, fileDuration, selectedSegments, safeSetCutSegments]);
const combineOverlappingSegments = useCallback(() => {
setCutSegments((existingSegments) => combineOverlappingSegments2(existingSegments));
}, [setCutSegments]);
safeSetCutSegments((existingSegments) => combineOverlappingSegments2(existingSegments));
}, [safeSetCutSegments]);
const combineSelectedSegments = useCallback(() => {
setCutSegments((existingSegments) => combineSelectedSegments2({ existingSegments, isSegmentSelected }));
}, [isSegmentSelected, setCutSegments]);
safeSetCutSegments((existingSegments) => combineSelectedSegments2({ existingSegments, isSegmentSelected }));
}, [isSegmentSelected, safeSetCutSegments]);
const updateSegAtIndex = useCallback<UpdateSegAtIndex>((index, newProps) => {
if (index < 0) return;
@ -285,11 +300,11 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea
const existing = cutSegments[index];
if (existing == null) throw new Error();
cutSegmentsNew.splice(index, 1, { ...existing, ...newProps });
setCutSegments(cutSegmentsNew);
}, [setCutSegments, cutSegments]);
safeSetCutSegments(cutSegmentsNew);
}, [safeSetCutSegments, cutSegments]);
const setCutTime = useCallback((type: 'start' | 'end', time: number | undefined) => {
if (!isDurationValid(fileDuration)) return;
if (!isDurationValid(fileDuration) || currentCutSeg == null) return;
if (type === 'start') {
invariant(time != null);
@ -306,23 +321,6 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea
}
}, [currentSegIndexSafe, currentCutSeg, fileDuration, updateSegAtIndex]);
const clampValue = useCallback((val: number | undefined) => {
if (val == null || Number.isNaN(val)) return undefined;
const clamped = Math.max(val, 0);
if (fileDuration == null) return clamped;
return Math.min(clamped, fileDuration);
}, [fileDuration]);
const safeSetCutSegments = useCallback((newSegments: StateSegment[]) => {
const safeNewSegments = newSegments.map(({ start, end, ...rest }) => ({
...rest,
start: clampValue(start) ?? 0,
end: clampValue(end),
})).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 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
@ -383,20 +381,22 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea
const removedSeg = newSegments.splice(index, 1)[0];
if (removedSeg == null) throw new Error();
newSegments.splice(newOrder, 0, removedSeg);
setCutSegments(newSegments);
safeSetCutSegments(newSegments);
setCurrentSegIndex(newOrder);
}, [cutSegments, setCurrentSegIndex, setCutSegments]);
}, [cutSegments, setCurrentSegIndex, safeSetCutSegments]);
const updateSegOrders = useCallback((newOrders: string[]) => {
const newSegments = sortBy(cutSegments, (seg) => newOrders.indexOf(seg.segId));
safeSetCutSegments(newSegments);
if (currentCutSeg != null) {
const newCurrentSegIndex = newOrders.indexOf(currentCutSeg.segId);
setCutSegments(newSegments);
if (newCurrentSegIndex >= 0 && newCurrentSegIndex < newSegments.length) setCurrentSegIndex(newCurrentSegIndex);
}, [cutSegments, setCutSegments, currentCutSeg, setCurrentSegIndex]);
}
}, [cutSegments, safeSetCutSegments, currentCutSeg, setCurrentSegIndex]);
const reorderSegsByStartTime = useCallback(() => {
setCutSegments(sortBy(cutSegments, (seg) => seg.start));
}, [cutSegments, setCutSegments]);
safeSetCutSegments(sortBy(cutSegments, (seg) => seg.start));
}, [cutSegments, safeSetCutSegments]);
const addSegment = useCallback(() => {
try {
@ -411,7 +411,7 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea
const newSegment = createIndexedSegment({ segment: { start: suggestedStart }, incrementCount: true });
// if initial segment, replace it instead
const cutSegmentsNew = cutSegments.length === 1 && cutSegments[0]!.start === undefined && cutSegments[0]!.end === undefined
const cutSegmentsNew = isInitialSegment(cutSegments)
? [
newSegment,
] : [
@ -419,12 +419,12 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea
newSegment,
];
setCutSegments(cutSegmentsNew);
safeSetCutSegments(cutSegmentsNew);
setCurrentSegIndex(cutSegmentsNew.length - 1);
} catch (err) {
console.error(err);
}
}, [getRelevantTime, fileDuration, cutSegments, createIndexedSegment, setCutSegments, setCurrentSegIndex]);
}, [getRelevantTime, fileDuration, cutSegments, createIndexedSegment, safeSetCutSegments, setCurrentSegIndex]);
const duplicateSegment = useCallback((segment: Pick<StateSegment, 'start' | 'end'> & Partial<Pick<StateSegment, 'name'>>) => {
try {
@ -436,19 +436,20 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea
createIndexedSegment({ segment: { start: segment.start, end: segment.end, name: segment.name }, incrementCount: true }),
];
setCutSegments(cutSegmentsNew);
safeSetCutSegments(cutSegmentsNew);
setCurrentSegIndex(cutSegmentsNew.length - 1);
} catch (err) {
console.error(err);
}
}, [createIndexedSegment, cutSegments, setCutSegments]);
}, [createIndexedSegment, cutSegments, safeSetCutSegments]);
const duplicateCurrentSegment = useCallback(() => {
if (currentCutSeg == null) return;
duplicateSegment(currentCutSeg);
}, [currentCutSeg, duplicateSegment]);
const setCutStart = useCallback(() => {
if (!checkFileOpened()) return;
if (!checkFileOpened() || currentCutSeg == null) return;
const relevantTime = getRelevantTime();
// https://github.com/mifi/lossless-cut/issues/168
@ -468,7 +469,7 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea
handleError(err);
}
}
}, [checkFileOpened, getRelevantTime, currentCutSeg.end, addSegment, setCutTime]);
}, [checkFileOpened, getRelevantTime, currentCutSeg, addSegment, setCutTime]);
const setCutEnd = useCallback(() => {
if (!checkFileOpened()) return;
@ -487,7 +488,9 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea
}, [checkFileOpened, getRelevantTime, setCutTime]);
const labelSegment = useCallback(async (index: number) => {
const { name } = cutSegments[index]!;
const seg = cutSegments[index];
if (seg == null) return;
const { name } = seg;
const value = await labelSegmentDialog({ currentName: name, maxLength: maxLabelLength });
if (value != null) updateSegAtIndex(index, { name: value });
}, [cutSegments, updateSegAtIndex, maxLabelLength]);
@ -532,8 +535,8 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea
const newSegments = [...cutSegments];
newSegments.splice(firstSegmentAtCursorIndex, 1, firstPart, secondPart);
setCutSegments(newSegments);
}, [createIndexedSegment, cutSegments, findSegmentsAtCursor, getRelevantTime, setCutSegments]);
safeSetCutSegments(newSegments);
}, [createIndexedSegment, cutSegments, findSegmentsAtCursor, getRelevantTime, safeSetCutSegments]);
const createNumSegments = useCallback(async () => {
if (!checkFileOpened() || !isDurationValid(fileDuration)) return;
@ -573,11 +576,10 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea
}, [cutSegments.length]);
const selectSegmentsByLabel = useCallback(async () => {
const { name } = currentCutSeg;
const value = await selectSegmentsByLabelDialog(name);
const value = await selectSegmentsByLabelDialog(currentCutSeg?.name);
if (value == null) return;
selectSegments(cutSegments.filter((seg) => seg.name === value));
}, [currentCutSeg, cutSegments, selectSegments]);
}, [currentCutSeg?.name, cutSegments, selectSegments]);
const selectAllMarkers = useCallback(() => {
selectSegments(cutSegments.filter((seg) => seg.end == null));
@ -666,11 +668,21 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea
const { name } = firstSelectedSegment;
const value = await labelSegmentDialog({ currentName: name, maxLength: maxLabelLength });
if (value == null) return;
setCutSegments((existingSegments) => existingSegments.map((existingSegment) => {
safeSetCutSegments((existingSegments) => existingSegments.map((existingSegment) => {
if (selectedSegmentsRaw.some((seg) => seg.segId === existingSegment.segId)) return { ...existingSegment, name: value };
return existingSegment;
}));
}, [maxLabelLength, selectedSegmentsRaw, setCutSegments]);
}, [maxLabelLength, selectedSegmentsRaw, safeSetCutSegments]);
const maybeCreateFullLengthSegment = useCallback((newFileDuration: number) => {
// don't use safeSetCutSegments because we want to set initial: true
setCutSegments((existing) => {
if (existing.length > 0 || newFileDuration <= 0) return existing;
const segment = { start: 0, end: newFileDuration, initial: true } as const;
console.log('Creating initial segment', segment);
return [createIndexedSegment({ segment })];
});
}, [createIndexedSegment, setCutSegments]);
const selectedSegmentsOrInverse = useMemo<{ start: number, end: number }[]>(() => {
// For invertCutSegments we do not support filtering (selecting) segments
@ -701,8 +713,14 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea
const invertSelectedSegments = useCallback(() => setDeselectedSegmentIds((existing) => Object.fromEntries(cutSegments.map((s) => [s.segId, !existing[s.segId]]))), [cutSegments]);
const selectAllSegments = useCallback(() => setDeselectedSegmentIds({}), []);
const selectOnlyCurrentSegment = useCallback(() => selectOnlySegment(currentCutSeg), [currentCutSeg, selectOnlySegment]);
const toggleCurrentSegmentSelected = useCallback(() => toggleSegmentSelected(currentCutSeg), [currentCutSeg, toggleSegmentSelected]);
const selectOnlyCurrentSegment = useCallback(() => {
if (currentCutSeg == null) return;
selectOnlySegment(currentCutSeg);
}, [currentCutSeg, selectOnlySegment]);
const toggleCurrentSegmentSelected = useCallback(() => {
if (currentCutSeg == null) return;
toggleSegmentSelected(currentCutSeg);
}, [currentCutSeg, toggleSegmentSelected]);
return {
cutSegments,
@ -739,12 +757,14 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea
currentCutSeg,
inverseCutSegments,
clearSegments,
clearSegColorCounter,
loadCutSegments,
isSegmentSelected,
selectedSegments,
selectedSegmentsOrInverse,
nonFilteredSegmentsOrInverse,
segmentsToExport,
maybeCreateFullLengthSegment,
setCurrentSegIndex,

@ -4,7 +4,7 @@ import isEqual from 'lodash/isEqual';
import isDev from '../isDev';
import { saveLlcProject } from '../edlStore';
import { createSegment, getCleanCutSegments } from '../segments';
import { getCleanCutSegments } from '../segments';
import { getSuffixedOutPath } from '../util';
import { StateSegment } from '../types';
import { errorToast } from '../swal';
@ -35,18 +35,17 @@ export default ({ autoSaveProjectFile, storeProjectInWorkingDir, filePath, custo
useEffect(() => {
async function save() {
try {
// NOTE: Could lose a save if user closes too fast, but not a big issue I think
if (!autoSaveProjectFile || !debouncedSaveOperation) return;
try {
// Initial state? Don't save (same as createInitialCutSegments but without counting)
if (isEqual(getCleanCutSegments(debouncedSaveOperation.cutSegments), getCleanCutSegments([createSegment()]))) return;
// Don't create llc file if no segments yet, or if initial segment:
if (debouncedSaveOperation.cutSegments.length === 0 || debouncedSaveOperation.cutSegments[0]?.initial) return;
if (lastSaveOperation.current && lastSaveOperation.current.projectFileSavePath === debouncedSaveOperation.projectFileSavePath && isEqual(getCleanCutSegments(lastSaveOperation.current.cutSegments), getCleanCutSegments(debouncedSaveOperation.cutSegments))) {
console.log('Segments unchanged, skipping save');
return;
}
if (debouncedSaveOperation.filePath == null) {
return;
}

@ -1,6 +1,5 @@
import { ReactEventHandler, useCallback, useMemo, useRef, useState } from 'react';
import { ChromiumHTMLVideoElement, PlaybackMode } from '../types';
import { isDurationValid } from '../segments';
import { showPlaybackFailedMessage } from '../swal';
export default ({ filePath }: { filePath: string | undefined }) => {
@ -9,7 +8,6 @@ export default ({ filePath }: { filePath: string | undefined }) => {
const [playbackRate, setPlaybackRateState] = useState(1);
const [outputPlaybackRate, setOutputPlaybackRateState] = useState(1);
const [playerTime, setPlayerTime] = useState<number>();
const [fileDuration, setFileDuration] = useState<number>();
const playbackModeRef = useRef<PlaybackMode>();
const videoRef = useRef<ChromiumHTMLVideoElement>(null);
@ -100,13 +98,6 @@ export default ({ filePath }: { filePath: string | undefined }) => {
}, []);
const onStartPlaying = useCallback(() => onPlayingChange(true), [onPlayingChange]);
const onDurationChange = useCallback<ReactEventHandler<HTMLVideoElement>>((e) => {
// Some files report duration infinity first, then proper duration later
// Sometimes after seeking to end of file, duration might change
const { duration: durationNew } = e.currentTarget;
console.log('onDurationChange', durationNew);
if (isDurationValid(durationNew)) setFileDuration(durationNew);
}, []);
const pause = useCallback(() => {
if (!filePath || !playingRef.current) return;
@ -154,9 +145,6 @@ export default ({ filePath }: { filePath: string | undefined }) => {
pause,
relevantTime,
getRelevantTime,
fileDuration,
setFileDuration,
onDurationChange,
onVideoAbort,
compatPlayerEventId,
setCompatPlayerEventId,

@ -9,7 +9,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'> => ({
export const createSegment = (props?: { start?: number | undefined, end?: number | undefined, name?: string | undefined, tags?: unknown | undefined, initial?: true }): Omit<StateSegment, 'segColorIndex'> => ({
start: props?.start ?? 0,
end: props?.end,
name: props?.name || '',
@ -20,6 +20,8 @@ export const createSegment = (props?: { start?: number | undefined, end?: number
tags: props?.tags != null && typeof props.tags === 'object'
? Object.fromEntries(Object.entries(props.tags).map(([key, value]) => [key, String(value)]))
: undefined,
...(props?.initial && { initial: true }),
});
export const addSegmentColorIndex = (segment: Omit<StateSegment, 'segColorIndex'>, segColorIndex: number): StateSegment => ({
@ -262,4 +264,4 @@ export function makeDurationSegments(segmentDuration: number, fileDuration: numb
return edl;
}
export const isInitialSegment = (segments: SegmentBase[]) => segments.length === 1 && segments[0]!.start === 0 && segments[0]!.end == null;
export const isInitialSegment = (segments: StateSegment[]) => segments.length === 0 || (segments.length === 1 && segments[0]!.initial);

@ -61,6 +61,7 @@ export interface StateSegment extends SegmentBase, SegmentColorIndex {
name: string;
segId: string;
tags?: SegmentTags | undefined;
initial?: true,
}
export interface SegmentToExport {

Loading…
Cancel
Save