diff --git a/package.json b/package.json index 6ef9bbc1..82b0a5ad 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "react-scripts": "^3.4.0", "react-sortable-hoc": "^1.5.3", "react-use": "^13.26.1", + "smpte-timecode": "^1.2.3", "strong-data-uri": "^1.0.5", "svg2png": "^4.1.1", "sweetalert2": "^9.10.10", diff --git a/public/configStore.js b/public/configStore.js index 992f4a5b..3ac70a3a 100644 --- a/public/configStore.js +++ b/public/configStore.js @@ -20,6 +20,7 @@ const defaults = { preserveMovData: false, avoidNegativeTs: 'make_zero', hideNotifications: undefined, + autoLoadTimecode: false, }, }; diff --git a/src/App.jsx b/src/App.jsx index e3a779ae..027b152d 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -42,7 +42,7 @@ import { getDefaultOutFormat, getFormatData, mergeFiles as ffmpegMergeFiles, renderThumbnails as ffmpegRenderThumbnails, readFrames, renderWaveformPng, html5ifyDummy, cutMultiple, extractStreams, autoMergeSegments, getAllStreams, findNearestKeyFrameTime, html5ify as ffmpegHtml5ify, isStreamThumbnail, isAudioSupported, isIphoneHevc, tryReadChaptersToEdl, - fixInvalidDuration, getDuration, + fixInvalidDuration, getDuration, getTimecodeFromStreams, } from './ffmpeg'; import { saveCsv, loadCsv, loadXmeml, loadCue } from './edlStore'; import { @@ -194,6 +194,8 @@ const App = memo(() => { useEffect(() => safeSetConfig('ffmpegExperimental', ffmpegExperimental), [ffmpegExperimental]); const [hideNotifications, setHideNotifications] = useState(configStore.get('hideNotifications')); useEffect(() => safeSetConfig('hideNotifications', hideNotifications), [hideNotifications]); + const [autoLoadTimecode, setAutoLoadTimecode] = useState(configStore.get('autoLoadTimecode')); + useEffect(() => safeSetConfig('autoLoadTimecode', autoLoadTimecode), [autoLoadTimecode]); useEffect(() => { i18n.changeLanguage(language || fallbackLng).catch(console.error); @@ -1193,6 +1195,11 @@ const App = memo(() => { const { streams } = await getAllStreams(fp); // console.log('streams', streamsNew); + if (autoLoadTimecode) { + const timecode = getTimecodeFromStreams(streams); + if (timecode) setStartTimeOffset(timecode); + } + const videoStream = streams.find(stream => stream.codec_type === 'video' && !isStreamThumbnail(stream)); const audioStream = streams.find(stream => stream.codec_type === 'audio'); setMainVideoStream(videoStream); @@ -1270,7 +1277,7 @@ const App = memo(() => { } finally { setWorking(); } - }, [resetState, working, createDummyVideo, loadEdlFile, getEdlFilePath, getHtml5ifiedPath, loadCutSegments, enableAskForImportChapters, showUnsupportedFileMessage]); + }, [resetState, working, createDummyVideo, loadEdlFile, getEdlFilePath, getHtml5ifiedPath, loadCutSegments, enableAskForImportChapters, showUnsupportedFileMessage, autoLoadTimecode]); const toggleHelp = useCallback(() => setHelpVisible(val => !val), []); const toggleSettings = useCallback(() => setSettingsVisible(val => !val), []); @@ -1883,12 +1890,14 @@ const App = memo(() => { setLanguage={setLanguage} hideNotifications={hideNotifications} setHideNotifications={setHideNotifications} + autoLoadTimecode={autoLoadTimecode} + setAutoLoadTimecode={setAutoLoadTimecode} AutoExportToggler={AutoExportToggler} renderCaptureFormatButton={renderCaptureFormatButton} onWheelTunerRequested={onWheelTunerRequested} /> - ), [AutoExportToggler, askBeforeClose, autoMerge, autoSaveProjectFile, customOutDir, invertCutSegments, keyframeCut, renderCaptureFormatButton, timecodeShowFrames, changeOutDir, onWheelTunerRequested, language, invertTimelineScroll, ffmpegExperimental, setFfmpegExperimental, enableAskForImportChapters, setEnableAskForImportChapters, enableAskForFileOpenAction, setEnableAskForFileOpenAction, hideNotifications, setHideNotifications]); + ), [AutoExportToggler, askBeforeClose, autoMerge, autoSaveProjectFile, customOutDir, invertCutSegments, keyframeCut, renderCaptureFormatButton, timecodeShowFrames, changeOutDir, onWheelTunerRequested, language, invertTimelineScroll, ffmpegExperimental, setFfmpegExperimental, enableAskForImportChapters, setEnableAskForImportChapters, enableAskForFileOpenAction, setEnableAskForFileOpenAction, hideNotifications, setHideNotifications, autoLoadTimecode, setAutoLoadTimecode]); useEffect(() => { if (!isStoreBuild) loadMifiLink().then(setMifiLink); diff --git a/src/Settings.jsx b/src/Settings.jsx index 1503612e..d6d5ae82 100644 --- a/src/Settings.jsx +++ b/src/Settings.jsx @@ -9,7 +9,7 @@ const Settings = memo(({ AutoExportToggler, renderCaptureFormatButton, onWheelTunerRequested, language, setLanguage, invertTimelineScroll, setInvertTimelineScroll, ffmpegExperimental, setFfmpegExperimental, enableAskForImportChapters, setEnableAskForImportChapters, enableAskForFileOpenAction, setEnableAskForFileOpenAction, - hideNotifications, setHideNotifications, + hideNotifications, setHideNotifications, autoLoadTimecode, setAutoLoadTimecode }) => { const { t } = useTranslation(); @@ -197,6 +197,17 @@ const Settings = memo(({ + + {t('Auto load timecode from file as an offset in the timeline?')} + + setAutoLoadTimecode(e.target.checked)} + /> + + + {t('Hide informational notifications?')} diff --git a/src/ffmpeg.js b/src/ffmpeg.js index 78f8be1e..17dd0abc 100644 --- a/src/ffmpeg.js +++ b/src/ffmpeg.js @@ -5,6 +5,7 @@ import sum from 'lodash/sum'; import sortBy from 'lodash/sortBy'; import moment from 'moment'; import i18n from 'i18next'; +import Timecode from 'smpte-timecode'; import { formatDuration, getOutPath, transferTimestamps, filenamify, isDurationValid } from './util'; @@ -897,3 +898,31 @@ export async function fixInvalidDuration({ filePath, fileFormat, customOutDir }) return outPath; } + +function parseTimecode(str, frameRate) { + // console.log(str, frameRate); + const t = Timecode(str, frameRate ? parseFloat(frameRate.toFixed(3)) : undefined); + if (!t) return undefined; + const seconds = ((t.hours * 60) + t.minutes) * 60 + t.seconds + (t.frames / t.frameRate); + return Number.isFinite(seconds) ? seconds : undefined; +} + +export function getTimecodeFromStreams(streams) { + console.log('Trying to load timecode'); + let foundTimecode; + streams.find((stream) => { + try { + if (stream.tags && stream.tags.timecode) { + const fps = getStreamFps(stream); + foundTimecode = parseTimecode(stream.tags.timecode, fps); + console.log('Loaded timecode', stream.tags.timecode, 'from stream', stream.index); + return true; + } + return undefined; + } catch (err) { + // console.warn('Failed to parse timecode from file streams', err); + return undefined; + } + }); + return foundTimecode; +} diff --git a/yarn.lock b/yarn.lock index edafe7dc..7e2004a8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12067,6 +12067,11 @@ slice-ansi@^2.1.0: astral-regex "^1.0.0" is-fullwidth-code-point "^2.0.0" +smpte-timecode@^1.2.3: + version "1.2.3" + resolved "https://registry.yarnpkg.com/smpte-timecode/-/smpte-timecode-1.2.3.tgz#272ac8826724ce793d956109cfd3982b2f6b1893" + integrity sha512-6U+vdStmprzmpzEWS+9pw8GfiChBcMmJOdJxBjaXlGJhB9U/jcQhvh0buxJzEfhuX8E0OfYJy4hayBgAO92dBg== + snapdragon-node@^2.0.1: version "2.1.1" resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b"