improve frame capture

- allow setting capture frame method https://github.com/mifi/lossless-cut/issues/88#issuecomment-1364636200
- allow changing quality #1141 #371
pull/1255/head
Mikael Finstad 4 years ago
parent c5b3885c80
commit 2c76993953
No known key found for this signature in database
GPG Key ID: 25AB36E3E81CBC26

@ -113,6 +113,8 @@ const defaults = {
storeProjectInWorkingDir: true, storeProjectInWorkingDir: true,
enableOverwriteOutput: true, enableOverwriteOutput: true,
mouseWheelZoomModifierKey: 'ctrl', mouseWheelZoomModifierKey: 'ctrl',
captureFrameMethod: 'ffmpeg',
captureFrameQuality: 0.95,
}; };
// For portable app: https://github.com/mifi/lossless-cut/issues/645 // For portable app: https://github.com/mifi/lossless-cut/issues/645

@ -201,7 +201,7 @@ const App = memo(() => {
const allUserSettings = useUserSettingsRoot(); const allUserSettings = useUserSettingsRoot();
const { 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; } = allUserSettings;
useEffect(() => { useEffect(() => {
@ -1409,16 +1409,16 @@ const App = memo(() => {
try { try {
const currentTime = getCurrentTime(); const currentTime = getCurrentTime();
const video = videoRef.current; const video = videoRef.current;
const outPath = usingPreviewFile const outPath = (usingPreviewFile || captureFrameMethod === 'ffmpeg')
? await captureFramesFfmpeg({ customOutDir, filePath, fromTime: currentTime, captureFormat, enableTransferTimestamps, numFrames: 1 }) ? await captureFramesFfmpeg({ customOutDir, filePath, fromTime: currentTime, captureFormat, enableTransferTimestamps, numFrames: 1, quality: captureFrameQuality })
: await captureFrameFromTag({ customOutDir, filePath, currentTime, captureFormat, video, enableTransferTimestamps }); : await captureFrameFromTag({ customOutDir, filePath, currentTime, captureFormat, video, enableTransferTimestamps, quality: captureFrameQuality });
if (!hideAllNotifications) openDirToast({ icon: 'success', filePath: outPath, text: `${i18n.t('Screenshot captured to:')} ${outPath}` }); if (!hideAllNotifications) openDirToast({ icon: 'success', filePath: outPath, text: `${i18n.t('Screenshot captured to:')} ${outPath}` });
} catch (err) { } catch (err) {
console.error(err); console.error(err);
errorToast(i18n.t('Failed to capture frame')); 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) => { const extractSegmentFramesAsImages = useCallback(async (index) => {
if (!filePath) return; if (!filePath) return;
@ -1429,14 +1429,14 @@ const App = memo(() => {
try { try {
setWorking(i18n.t('Extracting frames')); 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 }) }); if (!hideAllNotifications) openDirToast({ icon: 'success', filePath: outPath, text: i18n.t('Frames extracted to: {{path}}', { path: outputDir }) });
} catch (err) { } catch (err) {
handleError(err); handleError(err);
} finally { } finally {
setWorking(); 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]); const extractCurrentSegmentFramesAsImages = useCallback(() => extractSegmentFramesAsImages(currentSegIndexSafe), [currentSegIndexSafe, extractSegmentFramesAsImages]);
@ -1911,14 +1911,14 @@ const App = memo(() => {
if (!filePath) return; if (!filePath) return;
try { try {
const currentTime = getCurrentTime(); 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 (!(await addFileAsCoverArt(path))) return;
if (!hideAllNotifications) toast.fire({ text: i18n.t('Current frame has been set as cover art') }); if (!hideAllNotifications) toast.fire({ text: i18n.t('Current frame has been set as cover art') });
} catch (err) { } catch (err) {
console.error(err); console.error(err);
errorToast(i18n.t('Failed to capture frame')); 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) => { const batchLoadPaths = useCallback((newPaths, append) => {
setBatchFiles((existingFiles) => { setBatchFiles((existingFiles) => {

@ -8,36 +8,11 @@ import AutoExportToggler from './components/AutoExportToggler';
import useUserSettings from './hooks/useUserSettings'; import useUserSettings from './hooks/useUserSettings';
import { askForFfPath } from './dialogs'; import { askForFfPath } from './dialogs';
import { isMasBuild } from './util'; import { isMasBuild } from './util';
import { langNames } from './util/constants';
import { keyMap } from './hooks/useTimelineScroll'; 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 // eslint-disable-next-line react/jsx-props-no-spreading
const Row = (props) => <Table.Row height="auto" paddingY={12} {...props} />; const Row = (props) => <Table.Row height="auto" paddingY={12} {...props} />;
// eslint-disable-next-line react/jsx-props-no-spreading // eslint-disable-next-line react/jsx-props-no-spreading
@ -49,7 +24,7 @@ const Settings = memo(({
}) => { }) => {
const { t } = useTranslation(); 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 onLangChange = useCallback((e) => {
const { value } = e.target; const { value } = e.target;
@ -210,6 +185,23 @@ const Settings = memo(({
</Table.TextCell> </Table.TextCell>
</Row> </Row>
<Row>
<KeyCell>{t('Snapshot capture method')}</KeyCell>
<Table.TextCell>
<Button onClick={() => setCaptureFrameMethod((existing) => (existing === 'ffmpeg' ? 'videotag' : 'ffmpeg'))}>
{captureFrameMethod === 'ffmpeg' ? t('FFmpeg') : t('HTML video tag')}
</Button>
</Table.TextCell>
</Row>
<Row>
<KeyCell>{t('Snapshot capture quality')}</KeyCell>
<Table.TextCell>
<input type="range" min={1} max={1000} style={{ width: 200 }} value={Math.round(captureFrameQuality * 1000)} onChange={(e) => setCaptureFrameQuality(Math.max(Math.min(1, parseInt(e.target.value, 10) / 1000)), 0)} /><br />
{Math.round(captureFrameQuality * 100)}%
</Table.TextCell>
</Row>
<Row> <Row>
<KeyCell>{t('In timecode show')}</KeyCell> <KeyCell>{t('In timecode show')}</KeyCell>
<Table.TextCell> <Table.TextCell>

@ -3,24 +3,24 @@ import dataUriToBuffer from 'data-uri-to-buffer';
import { getSuffixedOutPath, transferTimestamps } from './util'; import { getSuffixedOutPath, transferTimestamps } from './util';
import { formatDuration } from './util/duration'; import { formatDuration } from './util/duration';
import { captureFrame as ffmpegCaptureFrame } from './ffmpeg'; import { captureFrames as ffmpegCaptureFrames } from './ffmpeg';
const fs = window.require('fs-extra'); const fs = window.require('fs-extra');
const mime = window.require('mime-types'); const mime = window.require('mime-types');
function getFrameFromVideo(video, format) { function getFrameFromVideo(video, format, quality) {
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.width = video.videoWidth; canvas.width = video.videoWidth;
canvas.height = video.videoHeight; canvas.height = video.videoHeight;
canvas.getContext('2d').drawImage(video, 0, 0); canvas.getContext('2d').drawImage(video, 0, 0);
const dataUri = canvas.toDataURL(`image/${format}`); const dataUri = canvas.toDataURL(`image/${format}`, quality);
return dataUriToBuffer(dataUri); 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 }); const time = formatDuration({ seconds: fromTime, fileNameFriendly: true });
let nameSuffix; let nameSuffix;
if (numFrames > 1) { if (numFrames > 1) {
@ -30,14 +30,14 @@ export async function captureFramesFfmpeg({ customOutDir, filePath, fromTime, ca
nameSuffix = `${time}.${captureFormat}`; nameSuffix = `${time}.${captureFormat}`;
} }
const outPath = getSuffixedOutPath({ customOutDir, filePath, nameSuffix }); 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); if (enableTransferTimestamps && numFrames === 1) await transferTimestamps(filePath, outPath, fromTime);
return outPath; return outPath;
} }
export async function captureFrameFromTag({ customOutDir, filePath, currentTime, captureFormat, video, enableTransferTimestamps }) { export async function captureFrameFromTag({ customOutDir, filePath, currentTime, captureFormat, video, enableTransferTimestamps, quality }) {
const buf = getFrameFromVideo(video, captureFormat); const buf = getFrameFromVideo(video, captureFormat, quality);
const ext = mime.extension(buf.type); const ext = mime.extension(buf.type);
const time = formatDuration({ seconds: currentTime, fileNameFriendly: true }); const time = formatDuration({ seconds: currentTime, fileNameFriendly: true });

@ -727,15 +727,17 @@ export async function extractWaveform({ filePath, outPath }) {
console.timeEnd('ffmpeg'); console.timeEnd('ffmpeg');
} }
const imageCaptureQuality = 3;
// See also capture-frame.js // 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([ await runFfmpeg([
'-ss', timestamp, '-ss', timestamp,
'-i', videoPath, '-i', videoPath,
'-vframes', numFrames, '-vframes', numFrames,
'-q:v', imageCaptureQuality, '-q:v', ffmpegQuality,
'-y', outPath, '-y', outPath,
]); ]);
} }

@ -117,6 +117,10 @@ export default () => {
useEffect(() => safeSetConfig('enableOverwriteOutput', enableOverwriteOutput), [enableOverwriteOutput]); useEffect(() => safeSetConfig('enableOverwriteOutput', enableOverwriteOutput), [enableOverwriteOutput]);
const [mouseWheelZoomModifierKey, setMouseWheelZoomModifierKey] = useState(safeGetConfigInitial('mouseWheelZoomModifierKey')); const [mouseWheelZoomModifierKey, setMouseWheelZoomModifierKey] = useState(safeGetConfigInitial('mouseWheelZoomModifierKey'));
useEffect(() => safeSetConfig('mouseWheelZoomModifierKey', mouseWheelZoomModifierKey), [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(() => { const resetKeyBindings = useCallback(() => {
configStore.reset('keyBindings'); configStore.reset('keyBindings');
@ -213,5 +217,9 @@ export default () => {
setEnableOverwriteOutput, setEnableOverwriteOutput,
mouseWheelZoomModifierKey, mouseWheelZoomModifierKey,
setMouseWheelZoomModifierKey, setMouseWheelZoomModifierKey,
captureFrameMethod,
setCaptureFrameMethod,
captureFrameQuality,
setCaptureFrameQuality,
}; };
}; };

@ -7,3 +7,29 @@ export const zoomMax = 2 ** 14;
export const rightBarWidth = 200; export const rightBarWidth = 200;
export const leftBarWidth = 240; 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: '한국어',
};

Loading…
Cancel
Save