Skip to content

Commit

Permalink
improve frame capture
Browse files Browse the repository at this point in the history
- allow setting capture frame method #88 (comment)
- allow changing quality #1141 #371
  • Loading branch information
mifi committed Jan 6, 2023
1 parent c5b3885 commit 2c76993
Show file tree
Hide file tree
Showing 7 changed files with 77 additions and 47 deletions.
2 changes: 2 additions & 0 deletions public/configStore.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 9 additions & 9 deletions src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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;
Expand All @@ -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]);

Expand Down Expand Up @@ -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) => {
Expand Down
46 changes: 19 additions & 27 deletions src/Settings.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => <Table.Row height="auto" paddingY={12} {...props} />;
// eslint-disable-next-line react/jsx-props-no-spreading
Expand All @@ -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;
Expand Down Expand Up @@ -210,6 +185,23 @@ const Settings = memo(({
</Table.TextCell>
</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>
<KeyCell>{t('In timecode show')}</KeyCell>
<Table.TextCell>
Expand Down
14 changes: 7 additions & 7 deletions src/capture-frame.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 });
Expand Down
10 changes: 6 additions & 4 deletions src/ffmpeg.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
]);
}
Expand Down
8 changes: 8 additions & 0 deletions src/hooks/useUserSettingsRoot.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -213,5 +217,9 @@ export default () => {
setEnableOverwriteOutput,
mouseWheelZoomModifierKey,
setMouseWheelZoomModifierKey,
captureFrameMethod,
setCaptureFrameMethod,
captureFrameQuality,
setCaptureFrameQuality,
};
};
26 changes: 26 additions & 0 deletions src/util/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: '한국어',
};

0 comments on commit 2c76993

Please sign in to comment.