diff --git a/public/configStore.js b/public/configStore.js index 8ce0ba7cb9d..e28ca7e3561 100644 --- a/public/configStore.js +++ b/public/configStore.js @@ -113,6 +113,8 @@ const defaults = { storeProjectInWorkingDir: true, enableOverwriteOutput: true, mouseWheelZoomModifierKey: 'ctrl', + captureFrameMethod: 'ffmpeg', + captureFrameQuality: 0.95, }; // For portable app: https://github.com/mifi/lossless-cut/issues/645 diff --git a/src/App.jsx b/src/App.jsx index d3223502896..6df6bbc1174 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -201,7 +201,7 @@ const App = memo(() => { const allUserSettings = useUserSettingsRoot(); const { - captureFormat, setCaptureFormat, customOutDir, setCustomOutDir, keyframeCut, setKeyframeCut, preserveMovData, setPreserveMovData, movFastStart, setMovFastStart, avoidNegativeTs, autoMerge, timecodeFormat, invertCutSegments, setInvertCutSegments, autoExportExtraStreams, askBeforeClose, enableAskForImportChapters, enableAskForFileOpenAction, playbackVolume, setPlaybackVolume, autoSaveProjectFile, wheelSensitivity, invertTimelineScroll, language, ffmpegExperimental, hideNotifications, autoLoadTimecode, autoDeleteMergedSegments, exportConfirmEnabled, setExportConfirmEnabled, segmentsToChapters, setSegmentsToChapters, preserveMetadataOnMerge, setPreserveMetadataOnMerge, setSimpleMode, outSegTemplate, setOutSegTemplate, keyboardSeekAccFactor, keyboardNormalSeekSpeed, enableTransferTimestamps, outFormatLocked, setOutFormatLocked, safeOutputFileName, setSafeOutputFileName, enableAutoHtml5ify, segmentsToChaptersOnly, keyBindings, setKeyBindings, resetKeyBindings, enableSmartCut, customFfPath, storeProjectInWorkingDir, enableOverwriteOutput, mouseWheelZoomModifierKey, + captureFormat, setCaptureFormat, customOutDir, setCustomOutDir, keyframeCut, setKeyframeCut, preserveMovData, setPreserveMovData, movFastStart, setMovFastStart, avoidNegativeTs, autoMerge, timecodeFormat, invertCutSegments, setInvertCutSegments, autoExportExtraStreams, askBeforeClose, enableAskForImportChapters, enableAskForFileOpenAction, playbackVolume, setPlaybackVolume, autoSaveProjectFile, wheelSensitivity, invertTimelineScroll, language, ffmpegExperimental, hideNotifications, autoLoadTimecode, autoDeleteMergedSegments, exportConfirmEnabled, setExportConfirmEnabled, segmentsToChapters, setSegmentsToChapters, preserveMetadataOnMerge, setPreserveMetadataOnMerge, setSimpleMode, outSegTemplate, setOutSegTemplate, keyboardSeekAccFactor, keyboardNormalSeekSpeed, enableTransferTimestamps, outFormatLocked, setOutFormatLocked, safeOutputFileName, setSafeOutputFileName, enableAutoHtml5ify, segmentsToChaptersOnly, keyBindings, setKeyBindings, resetKeyBindings, enableSmartCut, customFfPath, storeProjectInWorkingDir, enableOverwriteOutput, mouseWheelZoomModifierKey, captureFrameMethod, captureFrameQuality, } = allUserSettings; useEffect(() => { @@ -1409,16 +1409,16 @@ const App = memo(() => { try { const currentTime = getCurrentTime(); const video = videoRef.current; - const outPath = usingPreviewFile - ? await captureFramesFfmpeg({ customOutDir, filePath, fromTime: currentTime, captureFormat, enableTransferTimestamps, numFrames: 1 }) - : await captureFrameFromTag({ customOutDir, filePath, currentTime, captureFormat, video, enableTransferTimestamps }); + const outPath = (usingPreviewFile || captureFrameMethod === 'ffmpeg') + ? await captureFramesFfmpeg({ customOutDir, filePath, fromTime: currentTime, captureFormat, enableTransferTimestamps, numFrames: 1, quality: captureFrameQuality }) + : await captureFrameFromTag({ customOutDir, filePath, currentTime, captureFormat, video, enableTransferTimestamps, quality: captureFrameQuality }); if (!hideAllNotifications) openDirToast({ icon: 'success', filePath: outPath, text: `${i18n.t('Screenshot captured to:')} ${outPath}` }); } catch (err) { console.error(err); errorToast(i18n.t('Failed to capture frame')); } - }, [filePath, getCurrentTime, usingPreviewFile, customOutDir, captureFormat, enableTransferTimestamps, hideAllNotifications]); + }, [filePath, getCurrentTime, usingPreviewFile, captureFrameMethod, customOutDir, captureFormat, enableTransferTimestamps, captureFrameQuality, hideAllNotifications]); const extractSegmentFramesAsImages = useCallback(async (index) => { if (!filePath) return; @@ -1429,14 +1429,14 @@ const App = memo(() => { try { setWorking(i18n.t('Extracting frames')); - const outPath = await captureFramesFfmpeg({ customOutDir, filePath, fromTime: start, captureFormat, enableTransferTimestamps, numFrames }); + const outPath = await captureFramesFfmpeg({ customOutDir, filePath, fromTime: start, captureFormat, enableTransferTimestamps, numFrames, quality: captureFrameQuality }); if (!hideAllNotifications) openDirToast({ icon: 'success', filePath: outPath, text: i18n.t('Frames extracted to: {{path}}', { path: outputDir }) }); } catch (err) { handleError(err); } finally { setWorking(); } - }, [apparentCutSegments, captureFormat, customOutDir, enableTransferTimestamps, filePath, getFrameCount, hideAllNotifications, outputDir, setWorking]); + }, [apparentCutSegments, captureFormat, captureFrameQuality, customOutDir, enableTransferTimestamps, filePath, getFrameCount, hideAllNotifications, outputDir, setWorking]); const extractCurrentSegmentFramesAsImages = useCallback(() => extractSegmentFramesAsImages(currentSegIndexSafe), [currentSegIndexSafe, extractSegmentFramesAsImages]); @@ -1911,14 +1911,14 @@ const App = memo(() => { if (!filePath) return; try { const currentTime = getCurrentTime(); - const path = await captureFramesFfmpeg({ customOutDir, filePath, fromTime: currentTime, captureFormat, enableTransferTimestamps, numFrames: 1 }); + const path = await captureFramesFfmpeg({ customOutDir, filePath, fromTime: currentTime, captureFormat, enableTransferTimestamps, numFrames: 1, quality: captureFrameQuality }); if (!(await addFileAsCoverArt(path))) return; if (!hideAllNotifications) toast.fire({ text: i18n.t('Current frame has been set as cover art') }); } catch (err) { console.error(err); errorToast(i18n.t('Failed to capture frame')); } - }, [addFileAsCoverArt, captureFormat, customOutDir, enableTransferTimestamps, filePath, getCurrentTime, hideAllNotifications]); + }, [addFileAsCoverArt, captureFormat, captureFrameQuality, customOutDir, enableTransferTimestamps, filePath, getCurrentTime, hideAllNotifications]); const batchLoadPaths = useCallback((newPaths, append) => { setBatchFiles((existingFiles) => { diff --git a/src/Settings.jsx b/src/Settings.jsx index b5a8dd0c62d..1bef213901d 100644 --- a/src/Settings.jsx +++ b/src/Settings.jsx @@ -8,36 +8,11 @@ import AutoExportToggler from './components/AutoExportToggler'; import useUserSettings from './hooks/useUserSettings'; import { askForFfPath } from './dialogs'; import { isMasBuild } from './util'; +import { langNames } from './util/constants'; import { keyMap } from './hooks/useTimelineScroll'; -// https://www.electronjs.org/docs/api/locales -// See i18n.js -const langNames = { - en: 'English', - cs: 'Čeština', - de: 'Deutsch', - es: 'Español', - fr: 'Français', - it: 'Italiano', - nl: 'Nederlands', - nb: 'Norsk', - pl: 'Polski', - pt: 'Português', - pt_BR: 'português do Brasil', - fi: 'Suomi', - ru: 'русский', - // sr: 'Cрпски', - tr: 'Türkçe', - vi: 'Tiếng Việt', - ja: '日本語', - zh: '中文', - zh_Hant: '繁體中文', - zh_Hans: '简体中文', - ko: '한국어', -}; - // eslint-disable-next-line react/jsx-props-no-spreading const Row = (props) => ; // eslint-disable-next-line react/jsx-props-no-spreading @@ -49,7 +24,7 @@ const Settings = memo(({ }) => { const { t } = useTranslation(); - const { customOutDir, changeOutDir, keyframeCut, toggleKeyframeCut, timecodeFormat, setTimecodeFormat, invertCutSegments, setInvertCutSegments, askBeforeClose, setAskBeforeClose, enableAskForImportChapters, setEnableAskForImportChapters, enableAskForFileOpenAction, setEnableAskForFileOpenAction, autoSaveProjectFile, setAutoSaveProjectFile, invertTimelineScroll, setInvertTimelineScroll, language, setLanguage, ffmpegExperimental, setFfmpegExperimental, hideNotifications, setHideNotifications, autoLoadTimecode, setAutoLoadTimecode, enableTransferTimestamps, setEnableTransferTimestamps, enableAutoHtml5ify, setEnableAutoHtml5ify, customFfPath, setCustomFfPath, storeProjectInWorkingDir, setStoreProjectInWorkingDir, enableOverwriteOutput, setEnableOverwriteOutput, mouseWheelZoomModifierKey, setMouseWheelZoomModifierKey } = useUserSettings(); + const { customOutDir, changeOutDir, keyframeCut, toggleKeyframeCut, timecodeFormat, setTimecodeFormat, invertCutSegments, setInvertCutSegments, askBeforeClose, setAskBeforeClose, enableAskForImportChapters, setEnableAskForImportChapters, enableAskForFileOpenAction, setEnableAskForFileOpenAction, autoSaveProjectFile, setAutoSaveProjectFile, invertTimelineScroll, setInvertTimelineScroll, language, setLanguage, ffmpegExperimental, setFfmpegExperimental, hideNotifications, setHideNotifications, autoLoadTimecode, setAutoLoadTimecode, enableTransferTimestamps, setEnableTransferTimestamps, enableAutoHtml5ify, setEnableAutoHtml5ify, customFfPath, setCustomFfPath, storeProjectInWorkingDir, setStoreProjectInWorkingDir, enableOverwriteOutput, setEnableOverwriteOutput, mouseWheelZoomModifierKey, setMouseWheelZoomModifierKey, captureFrameMethod, setCaptureFrameMethod, captureFrameQuality, setCaptureFrameQuality } = useUserSettings(); const onLangChange = useCallback((e) => { const { value } = e.target; @@ -210,6 +185,23 @@ const Settings = memo(({ + + {t('Snapshot capture method')} + + + + + + + {t('Snapshot capture quality')} + + setCaptureFrameQuality(Math.max(Math.min(1, parseInt(e.target.value, 10) / 1000)), 0)} />
+ {Math.round(captureFrameQuality * 100)}% +
+
+ {t('In timecode show')} diff --git a/src/capture-frame.js b/src/capture-frame.js index adcb088ca61..191beed8c85 100644 --- a/src/capture-frame.js +++ b/src/capture-frame.js @@ -3,24 +3,24 @@ import dataUriToBuffer from 'data-uri-to-buffer'; import { getSuffixedOutPath, transferTimestamps } from './util'; import { formatDuration } from './util/duration'; -import { captureFrame as ffmpegCaptureFrame } from './ffmpeg'; +import { captureFrames as ffmpegCaptureFrames } from './ffmpeg'; const fs = window.require('fs-extra'); const mime = window.require('mime-types'); -function getFrameFromVideo(video, format) { +function getFrameFromVideo(video, format, quality) { const canvas = document.createElement('canvas'); canvas.width = video.videoWidth; canvas.height = video.videoHeight; canvas.getContext('2d').drawImage(video, 0, 0); - const dataUri = canvas.toDataURL(`image/${format}`); + const dataUri = canvas.toDataURL(`image/${format}`, quality); return dataUriToBuffer(dataUri); } -export async function captureFramesFfmpeg({ customOutDir, filePath, fromTime, captureFormat, enableTransferTimestamps, numFrames }) { +export async function captureFramesFfmpeg({ customOutDir, filePath, fromTime, captureFormat, enableTransferTimestamps, numFrames, quality }) { const time = formatDuration({ seconds: fromTime, fileNameFriendly: true }); let nameSuffix; if (numFrames > 1) { @@ -30,14 +30,14 @@ export async function captureFramesFfmpeg({ customOutDir, filePath, fromTime, ca nameSuffix = `${time}.${captureFormat}`; } const outPath = getSuffixedOutPath({ customOutDir, filePath, nameSuffix }); - await ffmpegCaptureFrame({ timestamp: fromTime, videoPath: filePath, outPath, numFrames }); + await ffmpegCaptureFrames({ timestamp: fromTime, videoPath: filePath, outPath, numFrames, quality }); if (enableTransferTimestamps && numFrames === 1) await transferTimestamps(filePath, outPath, fromTime); return outPath; } -export async function captureFrameFromTag({ customOutDir, filePath, currentTime, captureFormat, video, enableTransferTimestamps }) { - const buf = getFrameFromVideo(video, captureFormat); +export async function captureFrameFromTag({ customOutDir, filePath, currentTime, captureFormat, video, enableTransferTimestamps, quality }) { + const buf = getFrameFromVideo(video, captureFormat, quality); const ext = mime.extension(buf.type); const time = formatDuration({ seconds: currentTime, fileNameFriendly: true }); diff --git a/src/ffmpeg.js b/src/ffmpeg.js index 282f0a344e4..7df67d484d4 100644 --- a/src/ffmpeg.js +++ b/src/ffmpeg.js @@ -727,15 +727,17 @@ export async function extractWaveform({ filePath, outPath }) { console.timeEnd('ffmpeg'); } -const imageCaptureQuality = 3; - // See also capture-frame.js -export async function captureFrame({ timestamp, videoPath, outPath, numFrames }) { +export async function captureFrames({ timestamp, videoPath, outPath, numFrames, quality }) { + // Normal range for JPEG is 2-31 with 31 being the worst quality. + const min = 2; + const max = 31; + const ffmpegQuality = Math.min(Math.max(min, quality, Math.round((1 - quality) * (max - min) + min)), max); await runFfmpeg([ '-ss', timestamp, '-i', videoPath, '-vframes', numFrames, - '-q:v', imageCaptureQuality, + '-q:v', ffmpegQuality, '-y', outPath, ]); } diff --git a/src/hooks/useUserSettingsRoot.js b/src/hooks/useUserSettingsRoot.js index 2ab97f5aa59..dc6bdb38535 100644 --- a/src/hooks/useUserSettingsRoot.js +++ b/src/hooks/useUserSettingsRoot.js @@ -117,6 +117,10 @@ export default () => { useEffect(() => safeSetConfig('enableOverwriteOutput', enableOverwriteOutput), [enableOverwriteOutput]); const [mouseWheelZoomModifierKey, setMouseWheelZoomModifierKey] = useState(safeGetConfigInitial('mouseWheelZoomModifierKey')); useEffect(() => safeSetConfig('mouseWheelZoomModifierKey', mouseWheelZoomModifierKey), [mouseWheelZoomModifierKey]); + const [captureFrameMethod, setCaptureFrameMethod] = useState(safeGetConfigInitial('captureFrameMethod')); + useEffect(() => safeSetConfig('captureFrameMethod', captureFrameMethod), [captureFrameMethod]); + const [captureFrameQuality, setCaptureFrameQuality] = useState(safeGetConfigInitial('captureFrameQuality')); + useEffect(() => safeSetConfig('captureFrameQuality', captureFrameQuality), [captureFrameQuality]); const resetKeyBindings = useCallback(() => { configStore.reset('keyBindings'); @@ -213,5 +217,9 @@ export default () => { setEnableOverwriteOutput, mouseWheelZoomModifierKey, setMouseWheelZoomModifierKey, + captureFrameMethod, + setCaptureFrameMethod, + captureFrameQuality, + setCaptureFrameQuality, }; }; diff --git a/src/util/constants.js b/src/util/constants.js index 22685dd3330..f7b09b03fd1 100644 --- a/src/util/constants.js +++ b/src/util/constants.js @@ -7,3 +7,29 @@ export const zoomMax = 2 ** 14; export const rightBarWidth = 200; export const leftBarWidth = 240; + +// https://www.electronjs.org/docs/api/locales +// See i18n.js +export const langNames = { + en: 'English', + cs: 'Čeština', + de: 'Deutsch', + es: 'Español', + fr: 'Français', + it: 'Italiano', + nl: 'Nederlands', + nb: 'Norsk', + pl: 'Polski', + pt: 'Português', + pt_BR: 'português do Brasil', + fi: 'Suomi', + ru: 'русский', + // sr: 'Cрпски', + tr: 'Türkçe', + vi: 'Tiếng Việt', + ja: '日本語', + zh: '中文', + zh_Hant: '繁體中文', + zh_Hans: '简体中文', + ko: '한국어', +};