Skip to content

Commit

Permalink
add option to shift cut start frames
Browse files Browse the repository at this point in the history
and add types
  • Loading branch information
mifi committed Feb 14, 2024
1 parent 37af026 commit 9509680
Show file tree
Hide file tree
Showing 17 changed files with 149 additions and 71 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"@radix-ui/react-switch": "^1.0.1",
"@tsconfig/strictest": "^2.0.2",
"@tsconfig/vite-react": "^3.0.0",
"@types/lodash": "^4.14.202",
"@types/sortablejs": "^1.15.0",
"@typescript-eslint/eslint-plugin": "^6.17.0",
"@typescript-eslint/parser": "^6.17.0",
Expand Down
1 change: 1 addition & 0 deletions public/configStore.js
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ const defaults = {
darkMode: true,
preferStrongColors: false,
outputFileNameMinZeroPadding: 1,
cutFromAdjustmentFrames: 0,
};

// For portable app: https://github.com/mifi/lossless-cut/issues/645
Expand Down
26 changes: 15 additions & 11 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ import { rightBarWidth, leftBarWidth, ffmpegExtractWindow, zoomMax } from './uti
import BigWaveform from './components/BigWaveform';

import isDev from './isDev';
import { EdlFileType, FfmpegCommandLog, Html5ifyMode, TunerType } from './types';
import { EdlFileType, FfmpegCommandLog, FfprobeChapter, FfprobeFormat, FfprobeStream, Html5ifyMode, Thumbnail, TunerType } from './types';

const electron = window.require('electron');
const { exists } = window.require('fs-extra');
Expand Down Expand Up @@ -125,12 +125,12 @@ function App() {
const [customTagsByFile, setCustomTagsByFile] = useState({});
const [paramsByStreamId, setParamsByStreamId] = useState(new Map());
const [detectedFps, setDetectedFps] = useState<number>();
const [mainFileMeta, setMainFileMeta] = useState<{ streams: any[], formatData: any, chapters?: any }>({ streams: [], formatData: {} });
const [mainFileMeta, setMainFileMeta] = useState<{ streams: FfprobeStream[], formatData: FfprobeFormat, chapters?: FfprobeChapter[] }>({ streams: [], formatData: {} });
const [copyStreamIdsByFile, setCopyStreamIdsByFile] = useState<Record<string, Record<string, boolean>>>({});
const [streamsSelectorShown, setStreamsSelectorShown] = useState(false);
const [concatDialogVisible, setConcatDialogVisible] = useState(false);
const [zoomUnrounded, setZoom] = useState(1);
const [thumbnails, setThumbnails] = useState<{ from: number, url: string }[]>([]);
const [thumbnails, setThumbnails] = useState<Thumbnail[]>([]);
const [shortestFlag, setShortestFlag] = useState(false);
const [zoomWindowStartTime, setZoomWindowStartTime] = useState(0);
const [subtitlesByStreamId, setSubtitlesByStreamId] = useState<Record<string, { url: string, lang?: string }>>({});
Expand Down Expand Up @@ -191,7 +191,7 @@ function App() {
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, simpleMode, setSimpleMode, outSegTemplate, setOutSegTemplate, keyboardSeekAccFactor, keyboardNormalSeekSpeed, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, outFormatLocked, setOutFormatLocked, safeOutputFileName, setSafeOutputFileName, enableAutoHtml5ify, segmentsToChaptersOnly, keyBindings, setKeyBindings, resetKeyBindings, enableSmartCut, customFfPath, storeProjectInWorkingDir, setStoreProjectInWorkingDir, enableOverwriteOutput, mouseWheelZoomModifierKey, captureFrameMethod, captureFrameQuality, captureFrameFileNameFormat, enableNativeHevc, cleanupChoices, setCleanupChoices, darkMode, setDarkMode, preferStrongColors, outputFileNameMinZeroPadding,
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, simpleMode, setSimpleMode, outSegTemplate, setOutSegTemplate, keyboardSeekAccFactor, keyboardNormalSeekSpeed, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, outFormatLocked, setOutFormatLocked, safeOutputFileName, setSafeOutputFileName, enableAutoHtml5ify, segmentsToChaptersOnly, keyBindings, setKeyBindings, resetKeyBindings, enableSmartCut, customFfPath, storeProjectInWorkingDir, setStoreProjectInWorkingDir, enableOverwriteOutput, mouseWheelZoomModifierKey, captureFrameMethod, captureFrameQuality, captureFrameFileNameFormat, enableNativeHevc, cleanupChoices, setCleanupChoices, darkMode, setDarkMode, preferStrongColors, outputFileNameMinZeroPadding, cutFromAdjustmentFrames,
} = allUserSettings;

useEffect(() => {
Expand Down Expand Up @@ -345,7 +345,7 @@ function App() {

const activeVideoStream = useMemo(() => (activeVideoStreamIndex != null ? videoStreams.find((stream) => stream.index === activeVideoStreamIndex) : undefined) ?? mainVideoStream, [activeVideoStreamIndex, mainVideoStream, videoStreams]);
const activeAudioStream = useMemo(() => (activeAudioStreamIndex != null ? audioStreams.find((stream) => stream.index === activeAudioStreamIndex) : undefined) ?? mainAudioStream, [activeAudioStreamIndex, audioStreams, mainAudioStream]);
const activeSubtitle = useMemo(() => activeSubtitleStreamIndex != null ? subtitlesByStreamId[activeSubtitleStreamIndex] : undefined, [activeSubtitleStreamIndex, subtitlesByStreamId]);
const activeSubtitle = useMemo(() => (activeSubtitleStreamIndex != null ? subtitlesByStreamId[activeSubtitleStreamIndex] : undefined), [activeSubtitleStreamIndex, subtitlesByStreamId]);

// 360 means we don't modify rotation gtrgt
const isRotationSet = rotation !== 360;
Expand Down Expand Up @@ -673,7 +673,7 @@ function App() {
const toggleStripAudio = useCallback(() => toggleStripStream((stream) => stream.codec_type === 'audio'), [toggleStripStream]);
const toggleStripThumbnail = useCallback(() => toggleStripStream(isStreamThumbnail), [toggleStripStream]);

const thumnailsRef = useRef<{ from: number, url: string }[]>([]);
const thumnailsRef = useRef<Thumbnail[]>([]);
const thumnailsRenderingPromiseRef = useRef<Promise<void>>();

function addThumbnail(thumbnail) {
Expand Down Expand Up @@ -798,7 +798,7 @@ function App() {

const {
concatFiles, html5ifyDummy, cutMultiple, autoConcatCutSegments, html5ify, fixInvalidDuration,
} = useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, needSmartCut, enableOverwriteOutput, outputPlaybackRate });
} = useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, needSmartCut, enableOverwriteOutput, outputPlaybackRate, cutFromAdjustmentFrames });

const html5ifyAndLoad = useCallback(async (cod, fp, speed, hv, ha) => {
const usesDummyVideo = speed === 'fastest';
Expand Down Expand Up @@ -867,6 +867,7 @@ function App() {
setTotalProgress();
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (failedFiles.length > 0) toast.fire({ title: `${i18n.t('Failed to convert files:')} ${failedFiles.join(' ')}`, timer: null as any as undefined, showConfirmButton: true });
} catch (err) {
errorToast(i18n.t('Failed to batch convert to supported format'));
Expand Down Expand Up @@ -1078,10 +1079,10 @@ function App() {
// assume execa killed (aborted by user)
return;
}

if ('stdout' in err) console.error('stdout:', err.stdout);
if ('stderr' in err) console.error('stderr:', err.stderr);

if (isExecaFailure(err)) {
if (isOutOfSpaceError(err)) {
showDiskFull();
Expand Down Expand Up @@ -1296,10 +1297,10 @@ function App() {
// assume execa killed (aborted by user)
return;
}

if ('stdout' in err) console.error('stdout:', err.stdout);
if ('stderr' in err) console.error('stderr:', err.stderr);

if (isExecaFailure(err)) {
if (isOutOfSpaceError(err)) {
showDiskFull();
Expand Down Expand Up @@ -2305,6 +2306,8 @@ function App() {
}
}

// todo
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const actionsWithArgs: Record<string, (...args: any[]) => void> = {
openFiles: (filePaths: string[]) => { userOpenFiles(filePaths.map(resolvePathIfNeeded)); },
// todo separate actions per type and move them into mainActions? https://github.com/mifi/lossless-cut/issues/254#issuecomment-932649424
Expand All @@ -2320,6 +2323,7 @@ function App() {
}
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const actionsWithCatch: Readonly<[string, (event: unknown, ...a: any) => Promise<void>]>[] = [
// actions with arguments:
...Object.entries(actionsWithArgs).map(([key, fn]) => [
Expand Down
22 changes: 21 additions & 1 deletion src/components/ExportConfirm.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ const ExportConfirm = memo(({
}) => {
const { t } = useTranslation();

const { changeOutDir, keyframeCut, toggleKeyframeCut, preserveMovData, movFastStart, avoidNegativeTs, setAvoidNegativeTs, autoDeleteMergedSegments, exportConfirmEnabled, toggleExportConfirmEnabled, segmentsToChapters, toggleSegmentsToChapters, preserveMetadataOnMerge, togglePreserveMetadataOnMerge, enableSmartCut, setEnableSmartCut, effectiveExportMode, enableOverwriteOutput, setEnableOverwriteOutput, ffmpegExperimental, setFfmpegExperimental } = useUserSettings();
const { changeOutDir, keyframeCut, toggleKeyframeCut, preserveMovData, movFastStart, avoidNegativeTs, setAvoidNegativeTs, autoDeleteMergedSegments, exportConfirmEnabled, toggleExportConfirmEnabled, segmentsToChapters, toggleSegmentsToChapters, preserveMetadataOnMerge, togglePreserveMetadataOnMerge, enableSmartCut, setEnableSmartCut, effectiveExportMode, enableOverwriteOutput, setEnableOverwriteOutput, ffmpegExperimental, setFfmpegExperimental, cutFromAdjustmentFrames, setCutFromAdjustmentFrames } = useUserSettings();

const isMov = ffmpegIsMov(outFormat);
const isIpod = outFormat === 'ipod';
Expand Down Expand Up @@ -109,6 +109,10 @@ const ExportConfirm = memo(({
toast.fire({ icon: 'info', timer: 10000, text: `${avoidNegativeTs}: ${texts[avoidNegativeTs]}` });
}, [avoidNegativeTs]);

const onCutFromAdjustmentFramesHelpPress = useCallback(() => {
toast.fire({ icon: 'info', timer: 10000, text: i18n.t('Shift all segment start times forward by a number of frames before cutting in order to avoid starting at the wrong keyframe.') });
}, []);

const onFfmpegExperimentalHelpPress = useCallback(() => {
toast.fire({ icon: 'info', timer: 10000, text: t('Enable experimental ffmpeg features flag?') });
}, [t]);
Expand Down Expand Up @@ -318,6 +322,22 @@ const ExportConfirm = memo(({
</>
)}

{areWeCutting && (
<tr>
<td>
{t('Shift all start times')}
</td>
<td>
<Select value={cutFromAdjustmentFrames} onChange={(e) => setCutFromAdjustmentFrames(Number(e.target.value))} style={{ height: 20, marginLeft: 5 }}>
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((v) => <option key={v} value={v}>{t('+{{numFrames}} frames', { numFrames: v, count: v })}</option>)}
</Select>
</td>
<td>
<HelpIcon onClick={onCutFromAdjustmentFramesHelpPress} />
</td>
</tr>
)}

{isMov && (
<>
<tr>
Expand Down
27 changes: 20 additions & 7 deletions src/ffmpeg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,21 @@ function getIntervalAroundTime(time, window) {
};
}

interface Keyframe {
time: number,
createdAt: Date,
}

interface Frame extends Keyframe {
keyframe: boolean
}

export async function readFrames({ filePath, from, to, streamIndex }) {
const intervalsArgs = from != null && to != null ? ['-read_intervals', `${from}%${to}`] : [];
const { stdout } = await runFfprobe(['-v', 'error', ...intervalsArgs, '-show_packets', '-select_streams', streamIndex, '-show_entries', 'packet=pts_time,flags', '-of', 'json', filePath]);
const packetsFiltered = JSON.parse(stdout).packets
// todo types
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const packetsFiltered: Frame[] = (JSON.parse(stdout).packets as any[])
.map(p => ({
keyframe: p.flags[0] === 'K',
time: parseFloat(p.pts_time),
Expand All @@ -73,12 +84,14 @@ export async function readKeyframesAroundTime({ filePath, streamIndex, aroundTim
return frames.filter((frame) => frame.keyframe);
}

export const findKeyframeAtExactTime = (keyframes, time) => keyframes.find((keyframe) => Math.abs(keyframe.time - time) < 0.000001);
export const findNextKeyframe = (keyframes, time) => keyframes.find((keyframe) => keyframe.time >= time); // (assume they are already sorted)
const findPreviousKeyframe = (keyframes, time) => keyframes.findLast((keyframe) => keyframe.time <= time);
const findNearestKeyframe = (keyframes, time) => minBy(keyframes, (keyframe) => Math.abs(keyframe.time - time));
export const findKeyframeAtExactTime = (keyframes: Keyframe[], time: number) => keyframes.find((keyframe) => Math.abs(keyframe.time - time) < 0.000001);
export const findNextKeyframe = (keyframes: Keyframe[], time: number) => keyframes.find((keyframe) => keyframe.time >= time); // (assume they are already sorted)
const findPreviousKeyframe = (keyframes: Keyframe[], time: number) => keyframes.findLast((keyframe) => keyframe.time <= time);
const findNearestKeyframe = (keyframes: Keyframe[], time: number) => minBy(keyframes, (keyframe) => Math.abs(keyframe.time - time));

export type FindKeyframeMode = 'nearest' | 'before' | 'after';

function findKeyframe(keyframes, time, mode) {
function findKeyframe(keyframes: Keyframe[], time: number, mode: FindKeyframeMode) {
switch (mode) {
case 'nearest': return findNearestKeyframe(keyframes, time);
case 'before': return findPreviousKeyframe(keyframes, time);
Expand All @@ -87,7 +100,7 @@ function findKeyframe(keyframes, time, mode) {
}
}

export async function findKeyframeNearTime({ filePath, streamIndex, time, mode }) {
export async function findKeyframeNearTime({ filePath, streamIndex, time, mode }: { filePath: string, streamIndex: number, time: number, mode: FindKeyframeMode }) {
let keyframes = await readKeyframesAroundTime({ filePath, streamIndex, aroundTime: time, window: 10 });
let nearByKeyframe = findKeyframe(keyframes, time, mode);

Expand Down
Loading

0 comments on commit 9509680

Please sign in to comment.