From 251120dd0cecf7bc908fbdb729b7746f06672c8d Mon Sep 17 00:00:00 2001 From: Mikael Finstad Date: Thu, 20 Nov 2025 15:14:57 +0800 Subject: [PATCH] show estimated segment file size closes #2630 --- package.json | 2 +- src/renderer/src/App.tsx | 3 ++- src/renderer/src/SegmentList.tsx | 30 +++++++++++++++++++------- src/renderer/src/hooks/useSegments.tsx | 16 +++++++++++--- src/renderer/src/util.ts | 7 ++++++ yarn.lock | 10 ++++----- 6 files changed, 50 insertions(+), 18 deletions(-) diff --git a/package.json b/package.json index a7ac0e74..61b6a28c 100644 --- a/package.json +++ b/package.json @@ -109,7 +109,7 @@ "nanoid": "^5.0.9", "p-map": "^5.5.0", "p-retry": "^6.2.0", - "pretty-bytes": "^6.0.0", + "pretty-bytes": "^7.1.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-i18next": "^16.2.4", diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index c789445a..9eb09b72 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -325,7 +325,7 @@ function App() { }, [isFileOpened]); const { - cutSegments, cutSegmentsHistory, createSegmentsFromKeyframes, shuffleSegments, detectBlackScenes, detectSilentScenes, detectSceneChanges, removeSegment, invertAllSegments, fillSegmentsGaps, combineOverlappingSegments, combineSelectedSegments, shiftAllSegmentTimes, alignSegmentTimesToKeyframes, updateSegOrder, updateSegOrders, reorderSegsByStartTime, addSegment, setCutStart, setCutEnd, labelSegment, splitCurrentSegment, focusSegmentAtCursor, selectSegmentsAtCursor, createNumSegments, createFixedDurationSegments, createFixedByteSizedSegments, createRandomSegments, haveInvalidSegs, currentSegIndexSafe, currentCutSeg, inverseCutSegments, clearSegments, clearSegColorCounter, loadCutSegments, setCutTime, setCurrentSegIndex, labelSelectedSegments, deselectAllSegments, selectAllSegments, selectOnlyCurrentSegment, toggleCurrentSegmentSelected, invertSelectedSegments, removeSelectedSegments, selectSegmentsByLabel, selectSegmentsByExpr, selectAllMarkers, mutateSegmentsByExpr, toggleSegmentSelected, selectOnlySegment, selectedSegments, segmentsOrInverse, segmentsToExport, duplicateCurrentSegment, duplicateSegment, updateSegAtIndex, findSegmentsAtCursor, maybeCreateFullLengthSegment, currentCutSegOrWholeTimeline, segColorCounter, + cutSegments, cutSegmentsHistory, createSegmentsFromKeyframes, shuffleSegments, detectBlackScenes, detectSilentScenes, detectSceneChanges, removeSegment, invertAllSegments, fillSegmentsGaps, combineOverlappingSegments, combineSelectedSegments, shiftAllSegmentTimes, alignSegmentTimesToKeyframes, updateSegOrder, updateSegOrders, reorderSegsByStartTime, addSegment, setCutStart, setCutEnd, labelSegment, splitCurrentSegment, focusSegmentAtCursor, selectSegmentsAtCursor, createNumSegments, createFixedDurationSegments, createFixedByteSizedSegments, createRandomSegments, getSegEstimatedSize, haveInvalidSegs, currentSegIndexSafe, currentCutSeg, inverseCutSegments, clearSegments, clearSegColorCounter, loadCutSegments, setCutTime, setCurrentSegIndex, labelSelectedSegments, deselectAllSegments, selectAllSegments, selectOnlyCurrentSegment, toggleCurrentSegmentSelected, invertSelectedSegments, removeSelectedSegments, selectSegmentsByLabel, selectSegmentsByExpr, selectAllMarkers, mutateSegmentsByExpr, toggleSegmentSelected, selectOnlySegment, selectedSegments, segmentsOrInverse, segmentsToExport, duplicateCurrentSegment, duplicateSegment, updateSegAtIndex, findSegmentsAtCursor, maybeCreateFullLengthSegment, currentCutSegOrWholeTimeline, segColorCounter, } = useSegments({ filePath, workingRef, setWorking, setProgress, videoStream: activeVideoStream, fileDuration, getRelevantTime, maxLabelLength, checkFileOpened, invertCutSegments, segmentsToChaptersOnly, timecodePlaceholder, parseTimecode, appendFfmpegCommandLog, fileDurationNonZero, mainFileMeta, seekAbs, activeVideoStreamIndex, activeAudioStreamIndexes, handleError, showGenericDialog }); const { getEdlFilePath, projectFileSavePath, getProjectFileSavePath } = useSegmentsAutoSave({ autoSaveProjectFile, storeProjectInWorkingDir, filePath, customOutDir, cutSegments }); @@ -2541,6 +2541,7 @@ function App() { setEditingSegmentTags={setEditingSegmentTags} setEditingSegmentTagsSegmentIndex={setEditingSegmentTagsSegmentIndex} onEditSegmentTags={onEditSegmentTags} + getSegEstimatedSize={getSegEstimatedSize} /> )} diff --git a/src/renderer/src/SegmentList.tsx b/src/renderer/src/SegmentList.tsx index 3ef1f8cc..6ea31bff 100644 --- a/src/renderer/src/SegmentList.tsx +++ b/src/renderer/src/SegmentList.tsx @@ -9,6 +9,7 @@ import { restrictToVerticalAxis } from '@dnd-kit/modifiers'; import { useVirtualizer } from '@tanstack/react-virtual'; import { CSS } from '@dnd-kit/utilities'; import invariant from 'tiny-invariant'; +import prettyBytes from 'pretty-bytes'; import useContextMenu from './hooks/useContextMenu'; import useUserSettings from './hooks/useUserSettings'; @@ -62,6 +63,7 @@ const Segment = memo(({ onExtractSelectedSegmentsFramesAsImages, onInvertSelectedSegments, onDuplicateSegmentClick, + getSegEstimatedSize, }: { seg: StateSegment | InverseCutSegment, index: number, @@ -93,6 +95,7 @@ const Segment = memo(({ onExtractSelectedSegmentsFramesAsImages: () => void, onInvertSelectedSegments: UseSegments['invertSelectedSegments'], onDuplicateSegmentClick: UseSegments['duplicateSegment'], + getSegEstimatedSize: UseSegments['getSegEstimatedSize'], }) => { const { invertCutSegments, darkMode } = useUserSettings(); const { t } = useTranslation(); @@ -149,6 +152,7 @@ const Segment = memo(({ useContextMenu(ref, contextMenuTemplate); const duration = useMemo(() => (seg.end == null ? undefined : seg.end - seg.start), [seg]); + const estimatedSize = useMemo(() => getSegEstimatedSize(seg), [getSegEstimatedSize, seg]); const timeStr = useMemo(() => ( seg.end == null @@ -249,25 +253,32 @@ const Segment = memo(({ {...sortable.listeners} role="button" tabIndex={-1} - style={{ cursor, color: 'var(--gray-12)', marginBottom: duration != null ? 3 : undefined, display: 'flex', alignItems: 'center', height: 16 }} + style={{ cursor, color: 'var(--gray-12)', marginBottom: duration != null ? '.1em' : undefined, display: 'flex', alignItems: 'center', height: '1em' }} onClick={handleDraggableClick} > {renderNumber()} - {timeStr} + {timeStr} - {'name' in seg && seg.name && {seg.name}} + {'name' in seg && seg.name && {seg.name}} {Object.entries(tags).map(([name, value]) => ( - {name}:{value} + {name}:{value} ))} {duration != null && ( <> -
+
{t('Duration')} {formatTimecode({ seconds: duration, shorten: true })}
-
- {{ durationMsFormatted: Math.floor(duration * 1000) }} ms, {{ frameCount: (duration && getFrameCount(duration)) ?? '?' }} frames +
+ {{ durationMsFormatted: Math.floor(duration * 1000) }} ms + , {{ frameCount: (duration && getFrameCount(duration)) ?? '?' }} frames + {estimatedSize != null && ( + + , ~{prettyBytes(estimatedSize, { space: false, maximumFractionDigits: 1, minimumFractionDigits: 0 })} + + )} +
)} @@ -321,6 +332,7 @@ function SegmentList({ setEditingSegmentTags, setEditingSegmentTagsSegmentIndex, onEditSegmentTags, + getSegEstimatedSize, }: { width: number, formatTimecode: FormatTimecode, @@ -361,6 +373,7 @@ function SegmentList({ setEditingSegmentTags: Dispatch>, setEditingSegmentTagsSegmentIndex: Dispatch>, onEditSegmentTags: (index: number) => void, + getSegEstimatedSize: UseSegments['getSegEstimatedSize'], }) { const { t } = useTranslation(); const { getSegColor, nextSegColorIndex } = useSegColors(); @@ -474,7 +487,7 @@ function SegmentList({ )}
-
+
{t('Segments total:')}
{formatTimecode({ seconds: segmentsTotal })}
@@ -586,6 +599,7 @@ function SegmentList({ onSelectAllMarkers={onSelectAllMarkers} onInvertSelectedSegments={onInvertSelectedSegments} onDuplicateSegmentClick={onDuplicateSegmentClick} + getSegEstimatedSize={getSegEstimatedSize} /> ); } diff --git a/src/renderer/src/hooks/useSegments.tsx b/src/renderer/src/hooks/useSegments.tsx index 2c491646..8f30af17 100644 --- a/src/renderer/src/hooks/useSegments.tsx +++ b/src/renderer/src/hooks/useSegments.tsx @@ -9,7 +9,7 @@ import { FaLink } from 'react-icons/fa'; import TextInput from '../components/TextInput'; import { detectSceneChanges as ffmpegDetectSceneChanges, readFrames, mapTimesToSegments, findKeyframeNearTime } from '../ffmpeg'; -import { shuffleArray } from '../util'; +import { getFileSize, shuffleArray } from '../util'; import { errorToast } from '../swal'; import { createNumSegments as createNumSegmentsDialog, createFixedByteSixedSegments as createFixedByteSixedSegmentsDialog, createRandomSegments as createRandomSegmentsDialog, labelSegmentDialog, askForShiftSegments, askForAlignSegments, selectSegmentsByLabelDialog, askForSegmentDuration, toastError } from '../dialogs'; import { createSegment, sortSegments, invertSegments, combineOverlappingSegments as combineOverlappingSegments2, combineSelectedSegments as combineSelectedSegments2, isDurationValid, addSegmentColorIndex, filterNonMarkers, makeDurationSegments, isInitialSegment } from '../segments'; @@ -725,13 +725,22 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea const createFixedByteSizedSegments = useCallback(async () => { if (!checkFileOpened() || !isDurationValid(fileDuration)) return; - const fileSize = mainFileMeta && parseInt(mainFileMeta.formatData.size, 10); - invariant(fileSize != null && !Number.isNaN(fileSize)); + invariant(mainFileMeta != null); + const fileSize = getFileSize(mainFileMeta.formatData); + invariant(fileSize != null); const segmentDuration = await createFixedByteSixedSegmentsDialog({ fileDuration, fileSize }); if (segmentDuration == null) return; loadCutSegments({ segments: makeDurationSegments(segmentDuration, fileDuration), append: true, clampDuration: fileDuration }); }, [checkFileOpened, fileDuration, loadCutSegments, mainFileMeta]); + const getSegEstimatedSize = useCallback((segment: Pick) => { + if (mainFileMeta == null || !isDurationValid(fileDuration) || segment.end == null) return undefined; + const fileSize = getFileSize(mainFileMeta.formatData); + if (fileSize == null) return undefined; + const segDuration = segment.end - segment.start; + return Math.round((segDuration / fileDuration) * fileSize); + }, [fileDuration, mainFileMeta]); + const createRandomSegments = useCallback(async () => { if (!checkFileOpened() || currentCutSegOrWholeTimeline.duration <= 0) return; const segments = await createRandomSegmentsDialog(currentCutSegOrWholeTimeline.duration); @@ -978,6 +987,7 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea createFixedDurationSegments, createFixedByteSizedSegments, createRandomSegments, + getSegEstimatedSize, haveInvalidSegs, currentSegIndexSafe, currentCutSeg, diff --git a/src/renderer/src/util.ts b/src/renderer/src/util.ts index 9b3bf73b..568de531 100644 --- a/src/renderer/src/util.ts +++ b/src/renderer/src/util.ts @@ -11,6 +11,7 @@ import { ffmpegExtractWindow } from './util/constants'; import { appName } from '../../main/common'; import { Html5ifyMode } from '../../common/types'; import { UserFacingError } from '../errors'; +import { FFprobeFormat } from '../../common/ffprobe'; const { dirname, parse: parsePath, join, extname, isAbsolute, resolve, basename } = window.require('path'); const fsExtra = window.require('fs-extra'); @@ -398,6 +399,12 @@ export function getImportProjectType(filePath: string) { return edlFormatForExtension[matchingExt]; } +export function getFileSize(formatData: FFprobeFormat) { + const fileSize = parseInt(formatData.size, 10); + if (Number.isNaN(fileSize)) return undefined; + return fileSize; +} + export const calcShouldShowWaveform = (zoomedDuration: number | undefined) => (zoomedDuration != null && zoomedDuration < ffmpegExtractWindow * 8); export const calcShouldShowKeyframes = (zoomedDuration: number | undefined) => (zoomedDuration != null && zoomedDuration < ffmpegExtractWindow * 8); diff --git a/yarn.lock b/yarn.lock index d0799d70..4e0fbcc8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9152,7 +9152,7 @@ __metadata: nanoid: "npm:^5.0.9" p-map: "npm:^5.5.0" p-retry: "npm:^6.2.0" - pretty-bytes: "npm:^6.0.0" + pretty-bytes: "npm:^7.1.0" react: "npm:^18.2.0" react-dom: "npm:^18.2.0" react-i18next: "npm:^16.2.4" @@ -10815,10 +10815,10 @@ __metadata: languageName: node linkType: hard -"pretty-bytes@npm:^6.0.0": - version: 6.0.0 - resolution: "pretty-bytes@npm:6.0.0" - checksum: 10/0bb9f95e617236404b29a8392c6efd82d65805f622f5e809ecd70068102be857d4e3276c86d2a32fa2ef851cc29472e380945dab7bec83ec79bd57a19a10faf7 +"pretty-bytes@npm:^7.1.0": + version: 7.1.0 + resolution: "pretty-bytes@npm:7.1.0" + checksum: 10/75fb34fba44596dc69983039fce9288f3b247b513aba2d6dfe74727505fe43199fcd7a1cee95dbd5be56a431d30fbdb4d24d3a62b9cf22bb63fa706b81c2e540 languageName: node linkType: hard