mirror of
https://github.com/mifi/lossless-cut.git
synced 2024-11-22 10:22:31 +01:00
improve frame capture
- allow setting capture frame method https://github.com/mifi/lossless-cut/issues/88#issuecomment-1364636200 - allow changing quality #1141 #371
This commit is contained in:
parent
c5b3885c80
commit
2c76993953
@ -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
|
||||
|
18
src/App.jsx
18
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) => {
|
||||
|
@ -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
|
||||
@ -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(({
|
||||
</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>
|
||||
|
@ -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 });
|
||||
|
@ -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,
|
||||
]);
|
||||
}
|
||||
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
@ -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: '한국어',
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user