diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 4051ed86..951c39f4 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1264,7 +1264,7 @@ function App() { if (await tryOpenProjectPath(getEdlFilePathOld(fp, cod), 'csv')) return; // OK, we didn't find a project file, instead maybe try to create project (segments) from chapters - const edl = await tryMapChaptersToEdl(chapters); + const edl = tryMapChaptersToEdl(chapters); if (edl.length > 0 && enableAskForImportChapters && (await askForImportChapters())) { console.log('Convert chapters to segments', edl); loadCutSegments(edl); diff --git a/src/renderer/src/ffmpeg.ts b/src/renderer/src/ffmpeg.ts index edf7fefc..bb1e42c0 100644 --- a/src/renderer/src/ffmpeg.ts +++ b/src/renderer/src/ffmpeg.ts @@ -5,12 +5,11 @@ import Timecode from 'smpte-timecode'; import minBy from 'lodash/minBy'; import invariant from 'tiny-invariant'; -import { pcmAudioCodecs, getMapStreamsArgs, isMov } from './util/streams'; +import { pcmAudioCodecs, isMov } from './util/streams'; import { isExecaError } from './util'; import { isDurationValid } from './segments'; import { FFprobeChapter, FFprobeFormat, FFprobeProbeResult, FFprobeStream } from '../../../ffprobe'; import { parseSrt, parseSrtToSegments } from './edlFormats'; -import { CopyfileStreams } from './types'; import { UnsupportedFileError } from '../errors'; const { ffmpeg, fileTypePromise } = window.require('@electron/remote').require('./index.js'); @@ -18,7 +17,7 @@ const { ffmpeg, fileTypePromise } = window.require('@electron/remote').require(' const { renderWaveformPng, mapTimesToSegments, detectSceneChanges, captureFrames, captureFrame, getFfCommandLine, runFfmpegConcat, runFfmpegWithProgress, html5ify, getDuration, abortFfmpegs, runFfmpeg, runFfprobe, getFfmpegPath, setCustomFfPath } = ffmpeg; -export { renderWaveformPng, mapTimesToSegments, detectSceneChanges, captureFrames, captureFrame, getFfCommandLine, runFfmpegConcat, runFfmpegWithProgress, html5ify, getDuration, abortFfmpegs, runFfmpeg, runFfprobe, getFfmpegPath, setCustomFfPath }; +export { renderWaveformPng, mapTimesToSegments, detectSceneChanges, captureFrames, captureFrame, getFfCommandLine, runFfmpegConcat, runFfmpegWithProgress, html5ify, getDuration, abortFfmpegs, runFfmpeg, getFfmpegPath, setCustomFfPath }; export class RefuseOverwriteError extends Error { @@ -140,11 +139,11 @@ export async function findKeyframeNearTime({ filePath, streamIndex, time, mode } // todo this is not in use // https://stackoverflow.com/questions/14005110/how-to-split-a-video-using-ffmpeg-so-that-each-chunk-starts-with-a-key-frame // http://kicherer.org/joomla/index.php/de/blog/42-avcut-frame-accurate-video-cutting-with-only-small-quality-loss -export function getSafeCutTime(frames, cutTime, nextMode) { +export function getSafeCutTime(frames: (Frame & { time: number })[], cutTime: number, nextMode: boolean) { const sigma = 0.01; - const isCloseTo = (time1, time2) => Math.abs(time1 - time2) < sigma; + const isCloseTo = (time1: number, time2: number) => Math.abs(time1 - time2) < sigma; - let index; + let index: number; if (frames.length < 2) throw new Error(i18n.t('Less than 2 frames found')); @@ -152,7 +151,7 @@ export function getSafeCutTime(frames, cutTime, nextMode) { index = frames.findIndex((f) => f.keyframe && f.time >= cutTime - sigma); if (index === -1) throw new Error(i18n.t('Failed to find next keyframe')); if (index >= frames.length - 1) throw new Error(i18n.t('We are on the last frame')); - const { time } = frames[index]; + const { time } = frames[index]!; if (isCloseTo(time, cutTime)) { return undefined; // Already on keyframe, no need to modify cut time } @@ -174,7 +173,7 @@ export function getSafeCutTime(frames, cutTime, nextMode) { // Last frame of video, no need to modify cut time return undefined; } - if (frames[index + 1].keyframe) { + if (frames[index + 1]!.keyframe) { // Already on frame before keyframe, no need to modify cut time return undefined; } @@ -185,7 +184,7 @@ export function getSafeCutTime(frames, cutTime, nextMode) { if (index === 0) throw new Error(i18n.t('We are on the first keyframe')); // Use frame before the found keyframe - return frames[index - 1].time; + return frames[index - 1]!.time; } export function findNearestKeyFrameTime({ frames, time, direction, fps }: { frames: Frame[], time: number, direction: number, fps: number | undefined }) { @@ -197,7 +196,7 @@ export function findNearestKeyFrameTime({ frames, time, direction, fps }: { fram return nearestKeyFrame.time; } -export async function tryMapChaptersToEdl(chapters: FFprobeChapter[]) { +export function tryMapChaptersToEdl(chapters: FFprobeChapter[]) { try { return chapters.map((chapter) => { const start = parseFloat(chapter.start_time); @@ -558,56 +557,3 @@ export async function runFfmpegStartupCheck() { export const getExperimentalArgs = (ffmpegExperimental: boolean) => (ffmpegExperimental ? ['-strict', 'experimental'] : []); export const getVideoTimescaleArgs = (videoTimebase: number | undefined) => (videoTimebase != null ? ['-video_track_timescale', String(videoTimebase)] : []); - -// inspired by https://gist.github.com/fernandoherreradelasheras/5eca67f4200f1a7cc8281747da08496e -export async function cutEncodeSmartPart({ filePath, cutFrom, cutTo, outPath, outFormat, videoCodec, videoBitrate, videoTimebase, allFilesMeta, copyFileStreams, videoStreamIndex, ffmpegExperimental }: { - filePath: string, cutFrom: number, cutTo: number, outPath: string, outFormat: string, videoCodec: string, videoBitrate: number, videoTimebase: number, allFilesMeta, copyFileStreams: CopyfileStreams, videoStreamIndex: number, ffmpegExperimental: boolean, -}) { - function getVideoArgs({ streamIndex, outputIndex }: { streamIndex: number, outputIndex: number }) { - if (streamIndex !== videoStreamIndex) return undefined; - - const args = [ - `-c:${outputIndex}`, videoCodec, - `-b:${outputIndex}`, String(videoBitrate), - ]; - - // seems like ffmpeg handles this itself well when encoding same source file - // if (videoLevel != null) args.push(`-level:${outputIndex}`, videoLevel); - // if (videoProfile != null) args.push(`-profile:${outputIndex}`, videoProfile); - - return args; - } - - const mapStreamsArgs = getMapStreamsArgs({ - allFilesMeta, - copyFileStreams, - outFormat, - getVideoArgs, - }); - - const ffmpegArgs = [ - '-hide_banner', - // No progress if we set loglevel warning :( - // '-loglevel', 'warning', - - '-ss', cutFrom.toFixed(5), // if we don't -ss before -i, seeking will be slow for long files, see https://github.com/mifi/lossless-cut/issues/126#issuecomment-1135451043 - '-i', filePath, - '-ss', '0', // If we don't do this, the output seems to start with an empty black after merging with the encoded part - '-t', (cutTo - cutFrom).toFixed(5), - - ...mapStreamsArgs, - - // See https://github.com/mifi/lossless-cut/issues/170 - '-ignore_unknown', - - ...getVideoTimescaleArgs(videoTimebase), - - ...getExperimentalArgs(ffmpegExperimental), - - '-f', outFormat, '-y', outPath, - ]; - - await runFfmpeg(ffmpegArgs); - - return ffmpegArgs; -} diff --git a/src/renderer/src/hooks/useFfmpegOperations.ts b/src/renderer/src/hooks/useFfmpegOperations.ts index 0574f863..405019ab 100644 --- a/src/renderer/src/hooks/useFfmpegOperations.ts +++ b/src/renderer/src/hooks/useFfmpegOperations.ts @@ -5,7 +5,7 @@ import pMap from 'p-map'; import invariant from 'tiny-invariant'; import { getSuffixedOutPath, transferTimestamps, getOutFileExtension, getOutDir, deleteDispositionValue, getHtml5ifiedPath, unlinkWithRetry, getFrameDuration } from '../util'; -import { isCuttingStart, isCuttingEnd, runFfmpegWithProgress, getFfCommandLine, getDuration, createChaptersFromSegments, readFileMeta, cutEncodeSmartPart, getExperimentalArgs, html5ify as ffmpegHtml5ify, getVideoTimescaleArgs, logStdoutStderr, runFfmpegConcat, RefuseOverwriteError, runFfmpeg } from '../ffmpeg'; +import { isCuttingStart, isCuttingEnd, runFfmpegWithProgress, getFfCommandLine, getDuration, createChaptersFromSegments, readFileMeta, getExperimentalArgs, html5ify as ffmpegHtml5ify, getVideoTimescaleArgs, logStdoutStderr, runFfmpegConcat, RefuseOverwriteError, runFfmpeg } from '../ffmpeg'; import { getMapStreamsArgs, getStreamIdsToCopy } from '../util/streams'; import { getSmartCutParams } from '../smartcut'; import { isDurationValid } from '../segments'; @@ -418,6 +418,60 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea await transferTimestamps({ inPath: filePath, outPath, cutFrom, cutTo, treatInputFileModifiedTimeAsStart, duration: isDurationValid(videoDuration) ? videoDuration : undefined, treatOutputFileModifiedTimeAsStart }); }, [appendFfmpegCommandLog, cutFromAdjustmentFrames, filePath, getOutputPlaybackRateArgs, shouldSkipExistingFile, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart]); + // inspired by https://gist.github.com/fernandoherreradelasheras/5eca67f4200f1a7cc8281747da08496e + const cutEncodeSmartPart = useCallback(async ({ cutFrom, cutTo, outPath, outFormat, videoCodec, videoBitrate, videoTimebase, allFilesMeta, copyFileStreams, videoStreamIndex, ffmpegExperimental }: { + cutFrom: number, cutTo: number, outPath: string, outFormat: string, videoCodec: string, videoBitrate: number, videoTimebase: number, allFilesMeta: AllFilesMeta, copyFileStreams: CopyfileStreams, videoStreamIndex: number, ffmpegExperimental: boolean, + }) => { + invariant(filePath != null); + + function getVideoArgs({ streamIndex, outputIndex }: { streamIndex: number, outputIndex: number }) { + if (streamIndex !== videoStreamIndex) return undefined; + + const args = [ + `-c:${outputIndex}`, videoCodec, + `-b:${outputIndex}`, String(videoBitrate), + ]; + + // seems like ffmpeg handles this itself well when encoding same source file + // if (videoLevel != null) args.push(`-level:${outputIndex}`, videoLevel); + // if (videoProfile != null) args.push(`-profile:${outputIndex}`, videoProfile); + + return args; + } + + const mapStreamsArgs = getMapStreamsArgs({ + allFilesMeta, + copyFileStreams, + outFormat, + getVideoArgs, + }); + + const ffmpegArgs = [ + '-hide_banner', + // No progress if we set loglevel warning :( + // '-loglevel', 'warning', + + '-ss', cutFrom.toFixed(5), // if we don't -ss before -i, seeking will be slow for long files, see https://github.com/mifi/lossless-cut/issues/126#issuecomment-1135451043 + '-i', filePath, + '-ss', '0', // If we don't do this, the output seems to start with an empty black after merging with the encoded part + '-t', (cutTo - cutFrom).toFixed(5), + + ...mapStreamsArgs, + + // See https://github.com/mifi/lossless-cut/issues/170 + '-ignore_unknown', + + ...getVideoTimescaleArgs(videoTimebase), + + ...getExperimentalArgs(ffmpegExperimental), + + '-f', outFormat, '-y', outPath, + ]; + + appendFfmpegCommandLog(ffmpegArgs); + await runFfmpeg(ffmpegArgs); + }, [appendFfmpegCommandLog, filePath]); + const cutMultiple = useCallback(async ({ outputDir, customOutDir, segments, outSegFileNames, videoDuration, rotation, detectedFps, onProgress: onTotalProgress, keyframeCut, copyFileStreams, allFilesMeta, outFormat, shortestFlag, ffmpegExperimental, preserveMetadata, preserveMetadataOnMerge, preserveMovData, preserveChapters, movFastStart, avoidNegativeTs, customTagsByFile, paramsByStreamId, chapters, }: { @@ -509,8 +563,7 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea if (videoCodec == null || detectedVideoBitrate == null || videoTimebase == null) throw new Error(); invariant(filePath != null); invariant(outFormat != null); - const args = await cutEncodeSmartPart({ filePath, cutFrom, cutTo, outPath, outFormat, videoCodec, videoBitrate: smartCutCustomBitrate != null ? smartCutCustomBitrate * 1000 : detectedVideoBitrate, videoStreamIndex, videoTimebase, allFilesMeta, copyFileStreams: copyFileStreamsFiltered, ffmpegExperimental }); - appendFfmpegCommandLog(args); + await cutEncodeSmartPart({ cutFrom, cutTo, outPath, outFormat, videoCodec, videoBitrate: smartCutCustomBitrate != null ? smartCutCustomBitrate * 1000 : detectedVideoBitrate, videoStreamIndex, videoTimebase, allFilesMeta, copyFileStreams: copyFileStreamsFiltered, ffmpegExperimental }); } // If we are cutting within two keyframes, just encode the whole part and return that @@ -573,7 +626,7 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea } finally { if (chaptersPath) await tryDeleteFiles([chaptersPath]); } - }, [needSmartCut, filePath, losslessCutSingle, shouldSkipExistingFile, smartCutCustomBitrate, appendFfmpegCommandLog, concatFiles]); + }, [needSmartCut, filePath, losslessCutSingle, shouldSkipExistingFile, cutEncodeSmartPart, smartCutCustomBitrate, concatFiles]); const autoConcatCutSegments = useCallback(async ({ customOutDir, outFormat, segmentPaths, ffmpegExperimental, onProgress, preserveMovData, movFastStart, autoDeleteMergedSegments, chapterNames, preserveMetadataOnMerge, mergedOutFilePath }: { customOutDir: string | undefined,