1
0
mirror of https://github.com/mifi/lossless-cut.git synced 2024-11-24 11:22:34 +01:00
This commit is contained in:
Mikael Finstad 2024-05-20 12:17:53 +02:00
parent 918277bd75
commit 4892437b83
No known key found for this signature in database
GPG Key ID: 25AB36E3E81CBC26
13 changed files with 92 additions and 29 deletions

View File

@ -87,7 +87,7 @@ import BigWaveform from './components/BigWaveform';
import isDev from './isDev'; import isDev from './isDev';
import { Chapter, ChromiumHTMLVideoElement, CustomTagsByFile, EdlFileType, FfmpegCommandLog, FilesMeta, FormatTimecode, ParamsByStreamId, ParseTimecode, PlaybackMode, SegmentColorIndex, SegmentTags, SegmentToExport, StateSegment, Thumbnail, TunerType } from './types'; import { Chapter, ChromiumHTMLVideoElement, CustomTagsByFile, EdlFileType, FfmpegCommandLog, FilesMeta, FormatTimecode, ParamsByStreamId, ParseTimecode, PlaybackMode, SegmentColorIndex, SegmentTags, SegmentToExport, StateSegment, Thumbnail, TunerType } from './types';
import { CaptureFormat, KeyboardAction, Html5ifyMode } from '../../../types'; import { CaptureFormat, KeyboardAction, Html5ifyMode, WaveformMode } from '../../../types';
import { FFprobeChapter, FFprobeFormat, FFprobeStream } from '../../../ffprobe'; import { FFprobeChapter, FFprobeFormat, FFprobeStream } from '../../../ffprobe';
const electron = window.require('electron'); const electron = window.require('electron');
@ -151,7 +151,7 @@ function App() {
// State per application launch // State per application launch
const lastOpenedPathRef = useRef<string>(); const lastOpenedPathRef = useRef<string>();
const [waveformMode, setWaveformMode] = useState<'big-waveform' | 'waveform'>(); const [waveformMode, setWaveformMode] = useState<WaveformMode>();
const [thumbnailsEnabled, setThumbnailsEnabled] = useState(false); const [thumbnailsEnabled, setThumbnailsEnabled] = useState(false);
const [keyframesEnabled, setKeyframesEnabled] = useState(true); const [keyframesEnabled, setKeyframesEnabled] = useState(true);
const [showRightBar, setShowRightBar] = useState(true); const [showRightBar, setShowRightBar] = useState(true);
@ -215,7 +215,7 @@ function App() {
const videoRef = useRef<ChromiumHTMLVideoElement>(null); const videoRef = useRef<ChromiumHTMLVideoElement>(null);
const videoContainerRef = useRef<HTMLDivElement>(null); const videoContainerRef = useRef<HTMLDivElement>(null);
const setOutputPlaybackRate = useCallback((v) => { const setOutputPlaybackRate = useCallback((v: number) => {
setOutputPlaybackRateState(v); setOutputPlaybackRateState(v);
if (videoRef.current) videoRef.current.playbackRate = v; if (videoRef.current) videoRef.current.playbackRate = v;
}, []); }, []);
@ -1634,7 +1634,7 @@ function App() {
const toggleLastCommands = useCallback(() => setLastCommandsVisible((val) => !val), []); const toggleLastCommands = useCallback(() => setLastCommandsVisible((val) => !val), []);
const toggleSettings = useCallback(() => setSettingsVisible((val) => !val), []); const toggleSettings = useCallback(() => setSettingsVisible((val) => !val), []);
const seekClosestKeyframe = useCallback((direction) => { const seekClosestKeyframe = useCallback((direction: number) => {
const time = findNearestKeyFrameTime({ time: getRelevantTime(), direction }); const time = findNearestKeyFrameTime({ time: getRelevantTime(), direction });
if (time == null) return; if (time == null) return;
userSeekAbs(time); userSeekAbs(time);

View File

@ -1,4 +1,4 @@
import { memo, useCallback, useEffect, useMemo, useState } from 'react'; import { CSSProperties, Dispatch, SetStateAction, memo, useCallback, useEffect, useMemo, useState } from 'react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { MdRotate90DegreesCcw } from 'react-icons/md'; import { MdRotate90DegreesCcw } from 'react-icons/md';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -23,16 +23,18 @@ import { useSegColors } from './contexts';
import { isExactDurationMatch } from './util/duration'; import { isExactDurationMatch } from './util/duration';
import useUserSettings from './hooks/useUserSettings'; import useUserSettings from './hooks/useUserSettings';
import { askForPlaybackRate } from './dialogs'; import { askForPlaybackRate } from './dialogs';
import { ApparentCutSegment, FormatTimecode, ParseTimecode, SegmentToExport, StateSegment } from './types';
import { WaveformMode } from '../../../types';
const { clipboard } = window.require('electron'); const { clipboard } = window.require('electron');
const zoomOptions = Array.from({ length: 13 }).fill().map((unused, z) => 2 ** z); const zoomOptions = Array.from({ length: 13 }).fill(undefined).map((_unused, z) => 2 ** z);
const leftRightWidth = 100; const leftRightWidth = 100;
// eslint-disable-next-line react/display-name // eslint-disable-next-line react/display-name
const InvertCutModeButton = memo(({ invertCutSegments, setInvertCutSegments }) => { const InvertCutModeButton = memo(({ invertCutSegments, setInvertCutSegments }: { invertCutSegments: boolean, setInvertCutSegments: Dispatch<SetStateAction<boolean>> }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const onYinYangClick = useCallback(() => { const onYinYangClick = useCallback(() => {
@ -65,15 +67,26 @@ const InvertCutModeButton = memo(({ invertCutSegments, setInvertCutSegments }) =
// eslint-disable-next-line react/display-name // eslint-disable-next-line react/display-name
const CutTimeInput = memo(({ darkMode, cutTime, setCutTime, startTimeOffset, seekAbs, currentCutSeg, currentApparentCutSeg, isStart, formatTimecode, parseTimecode }) => { const CutTimeInput = memo(({ darkMode, cutTime, setCutTime, startTimeOffset, seekAbs, currentCutSeg, currentApparentCutSeg, isStart, formatTimecode, parseTimecode }: {
darkMode: boolean,
cutTime: number,
setCutTime: (type: 'start' | 'end', v: number) => void,
startTimeOffset: number,
seekAbs: (a: number) => void,
currentCutSeg: StateSegment,
currentApparentCutSeg: ApparentCutSegment,
isStart?: boolean,
formatTimecode: FormatTimecode,
parseTimecode: ParseTimecode,
}) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { getSegColor } = useSegColors(); const { getSegColor } = useSegColors();
const [cutTimeManual, setCutTimeManual] = useState(); const [cutTimeManual, setCutTimeManual] = useState<string>();
// Clear manual overrides if upstream cut time has changed // Clear manual overrides if upstream cut time has changed
useEffect(() => { useEffect(() => {
setCutTimeManual(); setCutTimeManual(undefined);
}, [setCutTimeManual, currentApparentCutSeg.start, currentApparentCutSeg.end]); }, [setCutTimeManual, currentApparentCutSeg.start, currentApparentCutSeg.end]);
const isCutTimeManualSet = () => cutTimeManual !== undefined; const isCutTimeManualSet = () => cutTimeManual !== undefined;
@ -83,7 +96,7 @@ const CutTimeInput = memo(({ darkMode, cutTime, setCutTime, startTimeOffset, see
return `.1em solid ${darkMode ? segColor.desaturate(0.4).lightness(50).string() : segColor.desaturate(0.2).lightness(60).string()}`; return `.1em solid ${darkMode ? segColor.desaturate(0.4).lightness(50).string() : segColor.desaturate(0.2).lightness(60).string()}`;
}, [currentCutSeg, darkMode, getSegColor]); }, [currentCutSeg, darkMode, getSegColor]);
const cutTimeInputStyle = { const cutTimeInputStyle: CSSProperties = {
border, borderRadius: 5, backgroundColor: 'var(--gray5)', transition: darkModeTransition, fontSize: 13, textAlign: 'center', padding: '1px 5px', marginTop: 0, marginBottom: 0, marginLeft: isStart ? 0 : 5, marginRight: isStart ? 5 : 0, boxSizing: 'border-box', fontFamily: 'inherit', width: 90, outline: 'none', border, borderRadius: 5, backgroundColor: 'var(--gray5)', transition: darkModeTransition, fontSize: 13, textAlign: 'center', padding: '1px 5px', marginTop: 0, marginBottom: 0, marginLeft: isStart ? 0 : 5, marginRight: isStart ? 5 : 0, boxSizing: 'border-box', fontFamily: 'inherit', width: 90, outline: 'none',
}; };
@ -92,7 +105,7 @@ const CutTimeInput = memo(({ darkMode, cutTime, setCutTime, startTimeOffset, see
try { try {
setCutTime(isStart ? 'start' : 'end', timeWithoutOffset); setCutTime(isStart ? 'start' : 'end', timeWithoutOffset);
seekAbs(timeWithoutOffset); seekAbs(timeWithoutOffset);
setCutTimeManual(); setCutTimeManual(undefined);
} catch (err) { } catch (err) {
console.error('Cannot set cut time', err); console.error('Cannot set cut time', err);
// If we get an error from setCutTime, remain in the editing state (cutTimeManual) // If we get an error from setCutTime, remain in the editing state (cutTimeManual)
@ -104,7 +117,7 @@ const CutTimeInput = memo(({ darkMode, cutTime, setCutTime, startTimeOffset, see
e.preventDefault(); e.preventDefault();
// Don't proceed if not a valid time value // Don't proceed if not a valid time value
const timeWithOffset = parseTimecode(cutTimeManual); const timeWithOffset = cutTimeManual != null ? parseTimecode(cutTimeManual) : undefined;
if (timeWithOffset === undefined) return; if (timeWithOffset === undefined) return;
trySetTime(timeWithOffset); trySetTime(timeWithOffset);
@ -158,7 +171,7 @@ const CutTimeInput = memo(({ darkMode, cutTime, setCutTime, startTimeOffset, see
title={isStart ? t('Manually input current segment\'s start time') : t('Manually input current segment\'s end time')} title={isStart ? t('Manually input current segment\'s start time') : t('Manually input current segment\'s end time')}
onChange={(e) => handleCutTimeInput(e.target.value)} onChange={(e) => handleCutTimeInput(e.target.value)}
onPaste={handleCutTimePaste} onPaste={handleCutTimePaste}
onBlur={() => setCutTimeManual()} onBlur={() => setCutTimeManual(undefined)}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
value={isCutTimeManualSet() value={isCutTimeManualSet()
? cutTimeManual ? cutTimeManual
@ -181,6 +194,54 @@ function BottomBar({
toggleShowThumbnails, toggleWaveformMode, waveformMode, showThumbnails, toggleShowThumbnails, toggleWaveformMode, waveformMode, showThumbnails,
outputPlaybackRate, setOutputPlaybackRate, outputPlaybackRate, setOutputPlaybackRate,
formatTimecode, parseTimecode, formatTimecode, parseTimecode,
}: {
zoom: number,
setZoom: Dispatch<SetStateAction<number>>,
timelineToggleComfortZoom: () => void,
isRotationSet: boolean,
rotation: number,
areWeCutting: boolean,
increaseRotation: () => void,
cleanupFilesDialog: () => void,
captureSnapshot: () => void,
onExportPress: () => void,
segmentsToExport: SegmentToExport[],
hasVideo: boolean,
seekAbs: (a: number) => void,
currentSegIndexSafe: number,
cutSegments: StateSegment[],
currentCutSeg: StateSegment,
setCutStart: () => void,
setCutEnd: () => void,
setCurrentSegIndex: Dispatch<SetStateAction<number>>,
jumpTimelineStart: () => void,
jumpTimelineEnd: () => void,
jumpCutEnd: () => void,
jumpCutStart: () => void,
startTimeOffset: number,
setCutTime: (type: 'start' | 'end', v: number) => void,
currentApparentCutSeg: ApparentCutSegment,
playing: boolean,
shortStep: (a: number) => void,
togglePlay: () => void,
toggleLoopSelectedSegments: () => void,
hasAudio: boolean,
keyframesEnabled: boolean,
toggleShowKeyframes: () => void,
seekClosestKeyframe: (a: number) => void,
detectedFps: number | undefined,
isFileOpened: boolean,
selectedSegments: ApparentCutSegment[],
darkMode: boolean,
setDarkMode: Dispatch<SetStateAction<boolean>>,
toggleShowThumbnails: () => void,
toggleWaveformMode: () => void,
waveformMode: WaveformMode | undefined,
showThumbnails: boolean,
outputPlaybackRate: number,
setOutputPlaybackRate: (v: number) => void,
formatTimecode: FormatTimecode,
parseTimecode: ParseTimecode,
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { getSegColor } = useSegColors(); const { getSegColor } = useSegColors();
@ -188,7 +249,7 @@ function BottomBar({
// ok this is a bit over-engineered but what the hell! // ok this is a bit over-engineered but what the hell!
const loopSelectedSegmentsButtonStyle = useMemo(() => { const loopSelectedSegmentsButtonStyle = useMemo(() => {
// cannot have less than 1 gradient element: // cannot have less than 1 gradient element:
const selectedSegmentsSafe = (selectedSegments.length > 1 ? selectedSegments : [selectedSegments[0], selectedSegments[0]]).slice(0, 10); const selectedSegmentsSafe = (selectedSegments.length > 1 ? selectedSegments : [selectedSegments[0]!, selectedSegments[0]!]).slice(0, 10);
const gradientColors = selectedSegmentsSafe.map((seg, i) => { const gradientColors = selectedSegmentsSafe.map((seg, i) => {
const segColor = getSegColorRaw(seg); const segColor = getSegColorRaw(seg);
@ -233,7 +294,7 @@ function BottomBar({
const opacity = seg ? undefined : 0.5; const opacity = seg ? undefined : 0.5;
const text = seg ? `${newIndex + 1}` : '-'; const text = seg ? `${newIndex + 1}` : '-';
const wide = text.length > 1; const wide = text.length > 1;
const segButtonStyle = { const segButtonStyle: CSSProperties = {
backgroundColor, opacity, padding: `6px ${wide ? 4 : 6}px`, borderRadius: 10, color: seg ? 'white' : undefined, fontSize: wide ? 12 : 14, width: 20, boxSizing: 'border-box', letterSpacing: -1, lineHeight: '10px', fontWeight: 'bold', margin: '0 6px', backgroundColor, opacity, padding: `6px ${wide ? 4 : 6}px`, borderRadius: 10, color: seg ? 'white' : undefined, fontSize: wide ? 12 : 14, width: 20, boxSizing: 'border-box', letterSpacing: -1, lineHeight: '10px', fontWeight: 'bold', margin: '0 6px',
}; };
@ -262,7 +323,7 @@ function BottomBar({
{hasAudio && ( {hasAudio && (
<GiSoundWaves <GiSoundWaves
size={24} size={24}
style={{ padding: '0 .1em', color: ['big-waveform', 'waveform'].includes(waveformMode) ? primaryTextColor : undefined }} style={{ padding: '0 .1em', color: waveformMode != null && ['big-waveform', 'waveform'].includes(waveformMode) ? primaryTextColor : undefined }}
role="button" role="button"
title={t('Show waveform')} title={t('Show waveform')}
onClick={() => toggleWaveformMode()} onClick={() => toggleWaveformMode()}

View File

@ -4,8 +4,8 @@ import Swal from '../swal';
// eslint-disable-next-line import/prefer-default-export // eslint-disable-next-line import/prefer-default-export
export async function askExtractFramesAsImages({ segmentsNumFrames, plural, fps }) { export async function askExtractFramesAsImages({ segmentsNumFrames, plural, fps }: { segmentsNumFrames: number, plural: boolean, fps: number }) {
const { value: captureChoice } = await Swal.fire({ const { value: captureChoice } = await Swal.fire<string>({
text: i18n.t(plural ? 'Extract frames of the selected segments as images' : 'Extract frames of the current segment as images'), text: i18n.t(plural ? 'Extract frames of the selected segments as images' : 'Extract frames of the current segment as images'),
icon: 'question', icon: 'question',
input: 'radio', input: 'radio',
@ -23,7 +23,7 @@ export async function askExtractFramesAsImages({ segmentsNumFrames, plural, fps
if (!captureChoice) return undefined; if (!captureChoice) return undefined;
let filter; let filter: string | undefined;
let estimatedMaxNumFiles = segmentsNumFrames; let estimatedMaxNumFiles = segmentsNumFrames;
if (captureChoice === 'thumbnailFilter') { if (captureChoice === 'thumbnailFilter') {
@ -44,7 +44,7 @@ export async function askExtractFramesAsImages({ segmentsNumFrames, plural, fps
} }
if (captureChoice === 'selectNthSec' || captureChoice === 'selectNthFrame') { if (captureChoice === 'selectNthSec' || captureChoice === 'selectNthFrame') {
let nthFrame; let nthFrame: number;
if (captureChoice === 'selectNthFrame') { if (captureChoice === 'selectNthFrame') {
const { value } = await Swal.fire({ const { value } = await Swal.fire({
text: i18n.t('Capture exactly one image every nth frame'), text: i18n.t('Capture exactly one image every nth frame'),

View File

@ -1,6 +1,8 @@
// This code is for future use (e.g. creating black video to fill in using same codec parameters) // This code is for future use (e.g. creating black video to fill in using same codec parameters)
export function parseLevel(videoStream) { import { FFprobeStream } from '../../../ffprobe';
export function parseLevel(videoStream: FFprobeStream) {
const { level: levelNumeric, codec_name: videoCodec } = videoStream; const { level: levelNumeric, codec_name: videoCodec } = videoStream;
if (levelNumeric == null || Number.isNaN(levelNumeric)) return undefined; if (levelNumeric == null || Number.isNaN(levelNumeric)) return undefined;
@ -9,7 +11,7 @@ export function parseLevel(videoStream) {
if (levelNumeric === 9) return '1b'; // 13 is 1.3. That are all like that (20 is 2.0, etc) except 1b which is 9. if (levelNumeric === 9) return '1b'; // 13 is 1.3. That are all like that (20 is 2.0, etc) except 1b which is 9.
let level = (levelNumeric / 10).toFixed(1); // https://stackoverflow.com/questions/42619191/what-does-level-mean-in-ffprobe-output let level = (levelNumeric / 10).toFixed(1); // https://stackoverflow.com/questions/42619191/what-does-level-mean-in-ffprobe-output
if (level >= 0) { if (parseFloat(level) >= 0) {
if (level.slice(-2) === '.0') level = level.slice(0, -2); // slice off .0 if (level.slice(-2) === '.0') level = level.slice(0, -2); // slice off .0
const validLevels = ['1', '1b', '1.1', '1.2', '1.3', '2', '2.1', '2.2', '3', '3.1', '3.2', '4', '4.1', '4.2', '5', '5.1', '5.2', '6', '6.1', '6.2']; // https://en.wikipedia.org/wiki/Advanced_Video_Coding#Levels const validLevels = ['1', '1b', '1.1', '1.2', '1.3', '2', '2.1', '2.2', '3', '3.1', '3.2', '4', '4.1', '4.2', '5', '5.1', '5.2', '6', '6.1', '6.2']; // https://en.wikipedia.org/wiki/Advanced_Video_Coding#Levels
if (validLevels.includes(level)) return level; if (validLevels.includes(level)) return level;
@ -17,7 +19,7 @@ export function parseLevel(videoStream) {
} else if (videoCodec === 'hevc') { } else if (videoCodec === 'hevc') {
// Note that on MacOS we don't use x265, but videotoolbox // Note that on MacOS we don't use x265, but videotoolbox
let level = (levelNumeric / 30).toFixed(1); // https://stackoverflow.com/questions/69983131/whats-the-difference-between-ffprobe-level-and-h-264-level let level = (levelNumeric / 30).toFixed(1); // https://stackoverflow.com/questions/69983131/whats-the-difference-between-ffprobe-level-and-h-264-level
if (level >= 0) { if (parseFloat(level) >= 0) {
if (level.slice(-2) === '.0') level = level.slice(0, -2); // slice off .0 if (level.slice(-2) === '.0') level = level.slice(0, -2); // slice off .0
const validLevels = ['1', '2', '2.1', '3', '3.1', '4', '4.1', '5', '5.1', '5.2', '6', '6.1', '6.2']; // https://en.wikipedia.org/wiki/High_Efficiency_Video_Coding_tiers_and_levels const validLevels = ['1', '2', '2.1', '3', '3.1', '4', '4.1', '5', '5.1', '5.2', '6', '6.1', '6.2']; // https://en.wikipedia.org/wiki/High_Efficiency_Video_Coding_tiers_and_levels
if (validLevels.includes(level)) return level; if (validLevels.includes(level)) return level;

View File

@ -230,7 +230,7 @@ function useSegments({ filePath, workingRef, setWorking, setCutProgress, videoSt
setCutSegments(cutSegmentsNew); setCutSegments(cutSegmentsNew);
}, [setCutSegments, cutSegments]); }, [setCutSegments, cutSegments]);
const setCutTime = useCallback((type, time) => { const setCutTime = useCallback((type: 'start' | 'end', time: number) => {
if (!isDurationValid(duration)) return; if (!isDurationValid(duration)) return;
const currentSeg = currentCutSeg; const currentSeg = currentCutSeg;

View File

@ -17,11 +17,11 @@ export async function loadMifiLink() {
} }
} }
export async function runStartupCheck({ ffmpeg }) { export async function runStartupCheck({ ffmpeg }: { ffmpeg: boolean }) {
try { try {
if (ffmpeg) await runFfmpegStartupCheck(); if (ffmpeg) await runFfmpegStartupCheck();
} catch (err) { } catch (err) {
if (['EPERM', 'EACCES'].includes(err.code)) { if (err instanceof Error && 'code' in err && typeof err.code === 'string' && ['EPERM', 'EACCES'].includes(err.code)) {
toast.fire({ toast.fire({
timer: 30000, timer: 30000,
icon: 'error', icon: 'error',

View File

@ -5,8 +5,6 @@
"noEmit": true, "noEmit": true,
"noImplicitAny": false, // todo "noImplicitAny": false, // todo
"checkJs": false, // todo
"allowJs": true, // todo
}, },
"references": [ "references": [
{ "path": "./tsconfig.main.json" }, { "path": "./tsconfig.main.json" },

View File

@ -112,6 +112,8 @@ export interface ApiKeyboardActionRequest {
export type Html5ifyMode = 'fastest' | 'fast-audio-remux' | 'fast-audio' | 'fast' | 'slow' | 'slow-audio' | 'slowest'; export type Html5ifyMode = 'fastest' | 'fast-audio-remux' | 'fast-audio' | 'fast' | 'slow' | 'slow-audio' | 'slowest';
export type WaveformMode = 'big-waveform' | 'waveform';
// This is the contract with the user, see https://github.com/mifi/lossless-cut/blob/master/expressions.md // This is the contract with the user, see https://github.com/mifi/lossless-cut/blob/master/expressions.md
export interface ScopeSegment { export interface ScopeSegment {
label: string, label: string,