From 75f768dad7c2bdc9b694c715756798a4bb5b0d68 Mon Sep 17 00:00:00 2001 From: Mikael Finstad Date: Fri, 6 Jan 2023 22:48:52 +0800 Subject: [PATCH] always use timecode format setting also when exporting files --- src/App.jsx | 24 +++++---- src/capture-frame.js | 93 -------------------------------- src/hooks/useFrameCapture.js | 101 +++++++++++++++++++++++++++++++++++ 3 files changed, 114 insertions(+), 104 deletions(-) delete mode 100644 src/capture-frame.js create mode 100644 src/hooks/useFrameCapture.js diff --git a/src/App.jsx b/src/App.jsx index 9a4dcedf..e9c1e9b1 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -23,6 +23,7 @@ import useKeyframes from './hooks/useKeyframes'; import useWaveform from './hooks/useWaveform'; import useKeyboard from './hooks/useKeyboard'; import useFileFormatState from './hooks/useFileFormatState'; +import useFrameCapture from './hooks/useFrameCapture'; import UserSettingsContext from './contexts/UserSettingsContext'; @@ -48,7 +49,6 @@ import OutputFormatSelect from './components/OutputFormatSelect'; import { loadMifiLink, runStartupCheck } from './mifi'; import { controlsBackground } from './colors'; -import { captureFrameFromTag, captureFrameFromFfmpeg, captureFramesRange } from './capture-frame'; import { getStreamFps, isCuttingStart, isCuttingEnd, readFileMeta, getSmarterOutFormat, renderThumbnails as ffmpegRenderThumbnails, @@ -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, captureFrameMethod, captureFrameQuality, + 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, captureFrameFileNameFormat, } = allUserSettings; useEffect(() => { @@ -489,17 +489,19 @@ const App = memo(() => { const getFrameCount = useCallback((sec) => getFrameCountRaw(detectedFps, sec), [detectedFps]); - const formatTimecode = useCallback(({ seconds, shorten }) => { + const formatTimecode = useCallback(({ seconds, shorten, fileNameFriendly }) => { if (timecodeFormat === 'frameCount') { const frameCount = getFrameCount(seconds); return frameCount != null ? frameCount : ''; } if (timecodeFormat === 'timecodeWithFramesFraction') { - return formatDuration({ seconds, fps: detectedFps, shorten }); + return formatDuration({ seconds, fps: detectedFps, shorten, fileNameFriendly }); } - return formatDuration({ seconds, shorten }); + return formatDuration({ seconds, shorten, fileNameFriendly }); }, [detectedFps, timecodeFormat, getFrameCount]); + const { captureFrameFromTag, captureFrameFromFfmpeg, captureFramesRange } = useFrameCapture({ formatTimecode }); + const getCurrentTime = useCallback(() => (playing ? videoRef.current.currentTime : commandedTimeRef.current), [playing]); // const getSafeCutTime = useCallback((cutTime, next) => ffmpeg.getSafeCutTime(neighbouringFrames, cutTime, next), [neighbouringFrames]); @@ -1208,8 +1210,8 @@ const App = memo(() => { const generateOutSegFileNames = useCallback(({ segments = segmentsToExport, template, forceSafeOutputFileName }) => ( segments.map((segment, i) => { const { start, end, name = '' } = segment; - const cutFromStr = formatDuration({ seconds: start, fileNameFriendly: true }); - const cutToStr = formatDuration({ seconds: end, fileNameFriendly: true }); + const cutFromStr = formatTimecode({ seconds: start, fileNameFriendly: true }); + const cutToStr = formatTimecode({ seconds: end, fileNameFriendly: true }); const segNum = i + 1; const filenamifyOrNot = (fileName) => (safeOutputFileName || forceSafeOutputFileName ? filenamify(fileName) : fileName).substr(0, maxLabelLength); @@ -1230,7 +1232,7 @@ const App = memo(() => { const generated = generateSegFileName({ template, segSuffix, inputFileNameWithoutExt: fileNameWithoutExt, ext, segNum, segLabel: nameSanitized, cutFrom: cutFromStr, cutTo: cutToStr, tags: tagsSanitized }); return safeOutputFileName ? generated.substring(0, 200) : generated; // If sanitation is enabled, make sure filename is not too long }) - ), [segmentsToExport, isCustomFormatSelected, fileFormat, filePath, safeOutputFileName, maxLabelLength]); + ), [segmentsToExport, formatTimecode, isCustomFormatSelected, fileFormat, filePath, safeOutputFileName, maxLabelLength]); const getOutSegError = useCallback((fileNames) => getOutSegErrorRaw({ fileNames, filePath, outputDir }), [outputDir, filePath]); @@ -1419,7 +1421,7 @@ const App = memo(() => { console.error(err); errorToast(i18n.t('Failed to capture frame')); } - }, [filePath, getCurrentTime, usingPreviewFile, captureFrameMethod, customOutDir, captureFormat, enableTransferTimestamps, captureFrameQuality, hideAllNotifications]); + }, [filePath, getCurrentTime, usingPreviewFile, captureFrameMethod, captureFrameFromFfmpeg, customOutDir, captureFormat, enableTransferTimestamps, captureFrameQuality, captureFrameFromTag, hideAllNotifications]); const extractSegmentFramesAsImages = useCallback(async (index) => { if (!filePath || detectedFps == null || workingRef.current) return; @@ -1440,7 +1442,7 @@ const App = memo(() => { setWorking(); setCutProgress(); } - }, [apparentCutSegments, captureFormat, captureFrameFileNameFormat, captureFrameQuality, customOutDir, detectedFps, filePath, getFrameCount, hideAllNotifications, outputDir, setWorking]); + }, [apparentCutSegments, captureFormat, captureFrameFileNameFormat, captureFrameQuality, captureFramesRange, customOutDir, detectedFps, filePath, getFrameCount, hideAllNotifications, outputDir, setWorking]); const extractCurrentSegmentFramesAsImages = useCallback(() => extractSegmentFramesAsImages(currentSegIndexSafe), [currentSegIndexSafe, extractSegmentFramesAsImages]); @@ -1922,7 +1924,7 @@ const App = memo(() => { console.error(err); errorToast(i18n.t('Failed to capture frame')); } - }, [addFileAsCoverArt, captureFormat, captureFrameQuality, customOutDir, enableTransferTimestamps, filePath, getCurrentTime, hideAllNotifications]); + }, [addFileAsCoverArt, captureFormat, captureFrameFromFfmpeg, captureFrameQuality, customOutDir, enableTransferTimestamps, filePath, getCurrentTime, hideAllNotifications]); const batchLoadPaths = useCallback((newPaths, append) => { setBatchFiles((existingFiles) => { diff --git a/src/capture-frame.js b/src/capture-frame.js deleted file mode 100644 index ea6860b4..00000000 --- a/src/capture-frame.js +++ /dev/null @@ -1,93 +0,0 @@ -import dataUriToBuffer from 'data-uri-to-buffer'; -import pMap from 'p-map'; - -import { getSuffixedOutPath, getOutDir, transferTimestamps, getSuffixedFileName, getOutPath, escapeRegExp } from './util'; -import { formatDuration } from './util/duration'; - -import { captureFrame as ffmpegCaptureFrame, captureFrames as ffmpegCaptureFrames } from './ffmpeg'; - -const fs = window.require('fs-extra'); -const mime = window.require('mime-types'); -const { rename, readdir } = window.require('fs/promises'); - -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}`, quality); - - return dataUriToBuffer(dataUri); -} - -export async function captureFramesRange({ customOutDir, filePath, fps, fromTime, toTime, estimatedMaxNumFiles, captureFormat, quality, filter, onProgress, outputTimestamps }) { - const getSuffix = (prefix) => `${prefix}.${captureFormat}`; - - if (!outputTimestamps) { - const numDigits = Math.floor(Math.log10(estimatedMaxNumFiles)) + 1; - const nameTemplateSuffix = getSuffix(`%0${numDigits}d`); - const nameSuffix = getSuffix(`${'1'.padStart(numDigits, '0')}`); // mimic ffmpeg output - const outPathTemplate = getSuffixedOutPath({ customOutDir, filePath, nameSuffix: nameTemplateSuffix }); - const firstFileOutPath = getSuffixedOutPath({ customOutDir, filePath, nameSuffix }); - - await ffmpegCaptureFrames({ from: fromTime, to: toTime, videoPath: filePath, outPathTemplate, quality, filter, onProgress }); - - return firstFileOutPath; - } - - // see https://github.com/mifi/lossless-cut/issues/1139 - const tmpSuffix = 'llc-tmp-frame-capture-'; - const outPathTemplate = getSuffixedOutPath({ customOutDir, filePath, nameSuffix: getSuffix(`${tmpSuffix}%d`) }); - await ffmpegCaptureFrames({ from: fromTime, to: toTime, videoPath: filePath, outPathTemplate, quality, filter, framePts: true, onProgress }); - - const outDir = getOutDir(customOutDir, filePath); - const files = await readdir(outDir); - - // https://github.com/mifi/lossless-cut/issues/1139 - const matches = files.map((fileName) => { - const escapedRegexp = escapeRegExp(getSuffixedFileName(filePath, tmpSuffix)); - const regexp = `^${escapedRegexp}(\\d+)`; - console.log(regexp); - const match = fileName.match(new RegExp(regexp)); - if (!match) return undefined; - const frameNum = parseInt(match[1], 10); - if (Number.isNaN(frameNum) || frameNum < 0) return undefined; - return { fileName, frameNum }; - }).filter((it) => it != null); - - console.log('Renaming temp files...'); - const outPaths = await pMap(matches, async ({ fileName, frameNum }) => { - const duration = formatDuration({ seconds: fromTime + (frameNum / fps), fileNameFriendly: true }); - const renameFromPath = getOutPath({ customOutDir, filePath, fileName }); - const renameToPath = getOutPath({ customOutDir, filePath, fileName: getSuffixedFileName(filePath, getSuffix(duration, captureFormat)) }); - await rename(renameFromPath, renameToPath); - return renameToPath; - }, { concurrency: 1 }); - - return outPaths[0]; -} - -export async function captureFrameFromFfmpeg({ customOutDir, filePath, fromTime, captureFormat, enableTransferTimestamps, quality }) { - const time = formatDuration({ seconds: fromTime, fileNameFriendly: true }); - const nameSuffix = `${time}.${captureFormat}`; - const outPath = getSuffixedOutPath({ customOutDir, filePath, nameSuffix }); - await ffmpegCaptureFrame({ timestamp: fromTime, videoPath: filePath, outPath, quality }); - - if (enableTransferTimestamps) await transferTimestamps(filePath, outPath, fromTime); - return outPath; -} - -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 }); - - const outPath = getSuffixedOutPath({ customOutDir, filePath, nameSuffix: `${time}.${ext}` }); - await fs.writeFile(outPath, buf); - - if (enableTransferTimestamps) await transferTimestamps(filePath, outPath, currentTime); - return outPath; -} diff --git a/src/hooks/useFrameCapture.js b/src/hooks/useFrameCapture.js new file mode 100644 index 00000000..c06bbaba --- /dev/null +++ b/src/hooks/useFrameCapture.js @@ -0,0 +1,101 @@ +import dataUriToBuffer from 'data-uri-to-buffer'; +import pMap from 'p-map'; + +import { getSuffixedOutPath, getOutDir, transferTimestamps, getSuffixedFileName, getOutPath, escapeRegExp } from '../util'; + +import { captureFrame as ffmpegCaptureFrame, captureFrames as ffmpegCaptureFrames } from '../ffmpeg'; + +const fs = window.require('fs-extra'); +const mime = window.require('mime-types'); +const { rename, readdir } = window.require('fs/promises'); + + +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}`, quality); + + return dataUriToBuffer(dataUri); +} + +export default ({ formatTimecode }) => { + async function captureFramesRange({ customOutDir, filePath, fps, fromTime, toTime, estimatedMaxNumFiles, captureFormat, quality, filter, onProgress, outputTimestamps }) { + const getSuffix = (prefix) => `${prefix}.${captureFormat}`; + + if (!outputTimestamps) { + const numDigits = Math.floor(Math.log10(estimatedMaxNumFiles)) + 1; + const nameTemplateSuffix = getSuffix(`%0${numDigits}d`); + const nameSuffix = getSuffix(`${'1'.padStart(numDigits, '0')}`); // mimic ffmpeg output + const outPathTemplate = getSuffixedOutPath({ customOutDir, filePath, nameSuffix: nameTemplateSuffix }); + const firstFileOutPath = getSuffixedOutPath({ customOutDir, filePath, nameSuffix }); + + await ffmpegCaptureFrames({ from: fromTime, to: toTime, videoPath: filePath, outPathTemplate, quality, filter, onProgress }); + + return firstFileOutPath; + } + + // see https://github.com/mifi/lossless-cut/issues/1139 + const tmpSuffix = 'llc-tmp-frame-capture-'; + const outPathTemplate = getSuffixedOutPath({ customOutDir, filePath, nameSuffix: getSuffix(`${tmpSuffix}%d`) }); + await ffmpegCaptureFrames({ from: fromTime, to: toTime, videoPath: filePath, outPathTemplate, quality, filter, framePts: true, onProgress }); + + const outDir = getOutDir(customOutDir, filePath); + const files = await readdir(outDir); + + // https://github.com/mifi/lossless-cut/issues/1139 + const matches = files.map((fileName) => { + const escapedRegexp = escapeRegExp(getSuffixedFileName(filePath, tmpSuffix)); + const regexp = `^${escapedRegexp}(\\d+)`; + console.log(regexp); + const match = fileName.match(new RegExp(regexp)); + if (!match) return undefined; + const frameNum = parseInt(match[1], 10); + if (Number.isNaN(frameNum) || frameNum < 0) return undefined; + return { fileName, frameNum }; + }).filter((it) => it != null); + + console.log('Renaming temp files...'); + const outPaths = await pMap(matches, async ({ fileName, frameNum }) => { + const duration = formatTimecode({ seconds: fromTime + (frameNum / fps), fileNameFriendly: true }); + const renameFromPath = getOutPath({ customOutDir, filePath, fileName }); + const renameToPath = getOutPath({ customOutDir, filePath, fileName: getSuffixedFileName(filePath, getSuffix(duration, captureFormat)) }); + await rename(renameFromPath, renameToPath); + return renameToPath; + }, { concurrency: 1 }); + + return outPaths[0]; + } + + async function captureFrameFromFfmpeg({ customOutDir, filePath, fromTime, captureFormat, enableTransferTimestamps, quality }) { + const time = formatTimecode({ seconds: fromTime, fileNameFriendly: true }); + const nameSuffix = `${time}.${captureFormat}`; + const outPath = getSuffixedOutPath({ customOutDir, filePath, nameSuffix }); + await ffmpegCaptureFrame({ timestamp: fromTime, videoPath: filePath, outPath, quality }); + + if (enableTransferTimestamps) await transferTimestamps(filePath, outPath, fromTime); + return outPath; + } + + async function captureFrameFromTag({ customOutDir, filePath, currentTime, captureFormat, video, enableTransferTimestamps, quality }) { + const buf = getFrameFromVideo(video, captureFormat, quality); + + const ext = mime.extension(buf.type); + const time = formatTimecode({ seconds: currentTime, fileNameFriendly: true }); + + const outPath = getSuffixedOutPath({ customOutDir, filePath, nameSuffix: `${time}.${ext}` }); + await fs.writeFile(outPath, buf); + + if (enableTransferTimestamps) await transferTimestamps(filePath, outPath, currentTime); + return outPath; + } + + return { + captureFramesRange, + captureFrameFromFfmpeg, + captureFrameFromTag, + }; +};