From 666a1c5bd44342f3eb096250476c706aec6301cc Mon Sep 17 00:00:00 2001 From: Mikael Finstad Date: Sun, 21 Apr 2024 23:44:10 +0200 Subject: [PATCH] fixes fix stream tags type fix waveform crash and bug with initial render --- ffprobe.ts | 6 +- src/renderer/src/App.tsx | 39 ++-- ...treamsSelector.jsx => StreamsSelector.tsx} | 179 +++++++++++------- src/renderer/src/dialogs/index.tsx | 2 +- src/renderer/src/hooks/useFfmpegOperations.ts | 16 +- src/renderer/src/hooks/useWaveform.ts | 14 +- src/renderer/src/types.ts | 17 ++ 7 files changed, 176 insertions(+), 97 deletions(-) rename src/renderer/src/{StreamsSelector.jsx => StreamsSelector.tsx} (68%) diff --git a/ffprobe.ts b/ffprobe.ts index 847bccf..392fc9a 100644 --- a/ffprobe.ts +++ b/ffprobe.ts @@ -251,7 +251,8 @@ export interface FFprobeStreamDisposition { /** * The "tags" field on an FFprobe response stream object */ -export interface FFprobeStreamTags { +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type FFprobeStreamTags = { /** * The track's language (usually represented using a 3 letter language code, e.g.: "eng") */ @@ -283,6 +284,9 @@ export interface FFprobeStreamTags { comment?: string rotate?: string, + + // https://github.com/mifi/lossless-cut/issues/1530 + title?: string, } /** diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index febe57f..f3ba01f 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -86,7 +86,7 @@ import { rightBarWidth, leftBarWidth, ffmpegExtractWindow, zoomMax } from './uti import BigWaveform from './components/BigWaveform'; import isDev from './isDev'; -import { ChromiumHTMLVideoElement, EdlFileType, FfmpegCommandLog, FormatTimecode, ParseTimecode, PlaybackMode, SegmentColorIndex, SegmentTags, SegmentToExport, StateSegment, Thumbnail, TunerType } from './types'; +import { ChromiumHTMLVideoElement, CustomTagsByFile, EdlFileType, FfmpegCommandLog, FilesMeta, FormatTimecode, ParamsByStreamId, ParseTimecode, PlaybackMode, SegmentColorIndex, SegmentTags, SegmentToExport, StateSegment, Thumbnail, TunerType } from './types'; import { CaptureFormat, KeyboardAction, Html5ifyMode } from '../../../types'; import { FFprobeChapter, FFprobeFormat, FFprobeStream } from '../../../ffprobe'; @@ -125,9 +125,9 @@ function App() { const [cutProgress, setCutProgress] = useState(); const [startTimeOffset, setStartTimeOffset] = useState(0); const [filePath, setFilePath] = useState(); - const [externalFilesMeta, setExternalFilesMeta] = useState>({}); - const [customTagsByFile, setCustomTagsByFile] = useState({}); - const [paramsByStreamId, setParamsByStreamId] = useState(new Map()); + const [externalFilesMeta, setExternalFilesMeta] = useState({}); + const [customTagsByFile, setCustomTagsByFile] = useState({}); + const [paramsByStreamId, setParamsByStreamId] = useState(new Map()); const [detectedFps, setDetectedFps] = useState(); const [mainFileMeta, setMainFileMeta] = useState<{ streams: FFprobeStream[], formatData: FFprobeFormat, chapters: FFprobeChapter[] }>(); const [copyStreamIdsByFile, setCopyStreamIdsByFile] = useState>>({}); @@ -253,7 +253,7 @@ function App() { setFfmpegCommandLog((old) => [...old, { command, time: new Date() }]); } - const setCopyStreamIdsForPath = useCallback((path, cb) => { + const setCopyStreamIdsForPath = useCallback[0]['setCopyStreamIdsForPath']>((path, cb) => { setCopyStreamIdsByFile((old) => { const oldIds = old[path] || {}; return ({ ...old, [path]: cb(oldIds) }); @@ -262,7 +262,7 @@ function App() { const toggleSegmentsList = useCallback(() => setShowRightBar((v) => !v), []); - const toggleCopyStreamId = useCallback((path, index) => { + const toggleCopyStreamId = useCallback((path: string, index: number) => { setCopyStreamIdsForPath(path, (old) => ({ ...old, [index]: !old[index] })); }, [setCopyStreamIdsForPath]); @@ -309,8 +309,8 @@ function App() { const mainFileFormatData = useMemo(() => mainFileMeta?.formatData, [mainFileMeta?.formatData]); const mainFileChapters = useMemo(() => mainFileMeta?.chapters, [mainFileMeta?.chapters]); - const isCopyingStreamId = useCallback((path, streamId) => ( - !!(copyStreamIdsByFile[path] || {})[streamId] + const isCopyingStreamId = useCallback((path: string | undefined, streamId: number) => ( + !!((path != null && copyStreamIdsByFile[path]) || {})[streamId] ), [copyStreamIdsByFile]); const checkCopyingAnyTrackOfType = useCallback((filter: (s: FFprobeStream) => boolean) => mainStreams.some((stream) => isCopyingStreamId(filePath, stream.index) && filter(stream)), [filePath, isCopyingStreamId, mainStreams]); @@ -694,6 +694,7 @@ function App() { const toggleStripStream = useCallback((filter) => { const copyingAnyTrackOfType = checkCopyingAnyTrackOfType(filter); + invariant(filePath != null); setCopyStreamIdsForPath(filePath, (old) => { const newCopyStreamIds = { ...old }; mainStreams.forEach((stream) => { @@ -763,7 +764,7 @@ function App() { const shouldShowWaveform = calcShouldShowWaveform(zoomedDuration); const { neighbouringKeyFrames, findNearestKeyFrameTime } = useKeyframes({ keyframesEnabled, filePath, commandedTime, videoStream: activeVideoStream, detectedFps, ffmpegExtractWindow }); - const { waveforms } = useWaveform({ darkMode, filePath, relevantTime, waveformEnabled, audioStream: activeAudioStream, ffmpegExtractWindow, durationSafe }); + const { waveforms } = useWaveform({ darkMode, filePath, relevantTime, waveformEnabled, audioStream: activeAudioStream, ffmpegExtractWindow, duration }); const resetMergedOutFileName = useCallback(() => { if (fileFormat == null || filePath == null) return; @@ -1833,19 +1834,23 @@ function App() { return fileMeta; }, [allFilesMeta, setCopyStreamIdsForPath]); - const updateStreamParams = useCallback((fileId, streamId, setter) => setParamsByStreamId(produce((draft) => { + const updateStreamParams = useCallback[0]['updateStreamParams']>((fileId, streamId, setter) => setParamsByStreamId(produce((draft) => { if (!draft.has(fileId)) draft.set(fileId, new Map()); const fileMap = draft.get(fileId); - if (!fileMap.has(streamId)) fileMap.set(streamId, new Map()); + invariant(fileMap != null); + if (!fileMap.has(streamId)) fileMap.set(streamId, {}); - setter(fileMap.get(streamId)); + const params = fileMap.get(streamId); + invariant(params != null); + setter(params); })), [setParamsByStreamId]); const addFileAsCoverArt = useCallback(async (path: string) => { const fileMeta = await addStreamSourceFile(path); if (!fileMeta) return false; const firstIndex = fileMeta.streams[0]!.index; - updateStreamParams(path, firstIndex, (params) => params.set('disposition', 'attached_pic')); + // eslint-disable-next-line no-param-reassign + updateStreamParams(path, firstIndex, (params) => { params.disposition = 'attached_pic'; }); return true; }, [addStreamSourceFile, updateStreamParams]); @@ -2280,7 +2285,7 @@ function App() { electron.ipcRenderer.send('setAskBeforeClose', askBeforeClose && isFileOpened); }, [askBeforeClose, isFileOpened]); - const extractSingleStream = useCallback(async (index) => { + const extractSingleStream = useCallback(async (index: number) => { if (!filePath) return; if (workingRef.current) return; @@ -2596,7 +2601,7 @@ function App() { - {showRightBar && isFileOpened && ( + {showRightBar && isFileOpened && filePath != null && ( setStreamsSelectorShown(false)} maxWidth={1000}> - {mainStreams && ( + {mainStreams && filePath != null && ( void) => void; -const EditFileDialog = memo(({ editingFile, allFilesMeta, customTagsByFile, setCustomTagsByFile, editingTag, setEditingTag }) => { +interface EditingStream { + streamId: number; + path: string; +} + +const EditFileDialog = memo(({ editingFile, allFilesMeta, customTagsByFile, setCustomTagsByFile, editingTag, setEditingTag }: { + editingFile: string, allFilesMeta: FilesMeta, customTagsByFile: CustomTagsByFile, setCustomTagsByFile: Dispatch>, editingTag: string | undefined, setEditingTag: (tag: string | undefined) => void +}) => { const { t } = useTranslation(); - const { formatData } = allFilesMeta[editingFile]; + const { formatData } = allFilesMeta[editingFile]!; const existingTags = formatData.tags || {}; const customTags = customTagsByFile[editingFile] || {}; - const onTagChange = useCallback((tag, value) => { - setCustomTagsByFile((old) => ({ ...old, [editingFile]: { ...old[editingFile], [tag]: value } })); + const onTagsChange = useCallback((keyValues: Record) => { + setCustomTagsByFile((old) => ({ ...old, [editingFile]: { ...old[editingFile], ...keyValues } })); }, [editingFile, setCustomTagsByFile]); - const onTagReset = useCallback((tag) => { + const onTagReset = useCallback((tag: string) => { setCustomTagsByFile((old) => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { [tag]: deleted, ...rest } = old[editingFile] || {}; @@ -39,13 +49,13 @@ const EditFileDialog = memo(({ editingFile, allFilesMeta, customTagsByFile, setC }); }, [editingFile, setCustomTagsByFile]); - return ; + return ; }); -const getStreamDispositionsObj = (stream) => ((stream && stream.disposition) || {}); +const getStreamDispositionsObj = (stream: FFprobeStream) => ((stream && stream.disposition) || {}); -function getStreamEffectiveDisposition(paramsByStreamId, fileId, stream) { - const customDisposition = paramsByStreamId.get(fileId)?.get(stream.index)?.get('disposition'); +function getStreamEffectiveDisposition(paramsByStreamId: ParamsByStreamId, fileId: string, stream: FFprobeStream) { + const customDisposition = paramsByStreamId.get(fileId)?.get(stream.index)?.disposition; const existingDispositionsObj = getStreamDispositionsObj(stream); if (customDisposition) return customDisposition; @@ -53,19 +63,23 @@ function getStreamEffectiveDisposition(paramsByStreamId, fileId, stream) { } -const StreamParametersEditor = ({ stream, streamParams, updateStreamParams }) => { +function StreamParametersEditor({ stream, streamParams, updateStreamParams }: { + stream: FFprobeStream, streamParams: StreamParams, updateStreamParams: (setter: (a: StreamParams) => void) => void, +}) { const { t } = useTranslation(); - const ui = []; + const ui: ReactNode[] = []; // https://github.com/mifi/lossless-cut/issues/1680#issuecomment-1682915193 if (stream.codec_name === 'h264') { ui.push( - updateStreamParams((params) => params.set('bsfH264Mp4toannexb', e.target.checked))} />, + // eslint-disable-next-line no-param-reassign + updateStreamParams((params) => { params.bsfH264Mp4toannexb = e.target.checked; })} />, ); } if (stream.codec_name === 'hevc') { ui.push( - updateStreamParams((params) => params.set('bsfHevcMp4toannexb', e.target.checked))} />, + // eslint-disable-next-line no-param-reassign + updateStreamParams((params) => { params.bsfHevcMp4toannexb = e.target.checked; })} />, ); } @@ -76,34 +90,39 @@ const StreamParametersEditor = ({ stream, streamParams, updateStreamParams }) => : t('No editable parameters for this stream.')} ); -}; +} -const EditStreamDialog = memo(({ editingStream: { streamId: editingStreamId, path: editingFile }, setEditingStream, allFilesMeta, paramsByStreamId, updateStreamParams }) => { +const EditStreamDialog = memo(({ editingStream: { streamId: editingStreamId, path: editingFile }, setEditingStream, allFilesMeta, paramsByStreamId, updateStreamParams }: { + editingStream: EditingStream, setEditingStream: Dispatch>, allFilesMeta: FilesMeta, paramsByStreamId: ParamsByStreamId, updateStreamParams: UpdateStreamParams, +}) => { const { t } = useTranslation(); - const [editingTag, setEditingTag] = useState(); + const [editingTag, setEditingTag] = useState(); - const { streams } = allFilesMeta[editingFile]; + const { streams } = allFilesMeta[editingFile]!; const editingStream = useMemo(() => streams.find((s) => s.index === editingStreamId), [streams, editingStreamId]); const existingTags = useMemo(() => (editingStream && editingStream.tags) || {}, [editingStream]); - const streamParams = useMemo(() => paramsByStreamId.get(editingFile)?.get(editingStreamId) ?? new Map(), [editingFile, editingStreamId, paramsByStreamId]); - const customTags = useMemo(() => streamParams.get('customTags') ?? {}, [streamParams]); + const streamParams = useMemo(() => paramsByStreamId.get(editingFile)?.get(editingStreamId) ?? {}, [editingFile, editingStreamId, paramsByStreamId]); + const customTags = useMemo(() => streamParams.customTags, [streamParams]); - const onTagChange = useCallback((tag, value) => { + const onTagsChange = useCallback((keyValues: Record) => { updateStreamParams(editingFile, editingStreamId, (params) => { - if (!params.has('customTags')) params.set('customTags', {}); - const tags = params.get('customTags'); - tags[tag] = value; + // eslint-disable-next-line no-param-reassign + if (params.customTags == null) params.customTags = {}; + const tags = params.customTags; + Object.entries(keyValues).forEach(([tag, value]) => { + tags[tag] = value; + }); }); }, [editingFile, editingStreamId, updateStreamParams]); - const onTagReset = useCallback((tag) => { + const onTagReset = useCallback((tag: string) => { updateStreamParams(editingFile, editingStreamId, (params) => { - if (!params.has('customTags')) return; + if (params.customTags == null) return; // todo // eslint-disable-next-line no-param-reassign, @typescript-eslint/no-dynamic-delete - delete params.get('customTags')[tag]; + delete params.customTags[tag]; }); }, [editingFile, editingStreamId, updateStreamParams]); @@ -114,32 +133,34 @@ const EditStreamDialog = memo(({ editingStream: { streamId: editingStreamId, pat hasCancel={false} isConfirmDisabled={editingTag != null} confirmLabel={t('Done')} - onCloseComplete={() => setEditingStream()} + onCloseComplete={() => setEditingStream(undefined)} >
Parameters - updateStreamParams(editingFile, editingStreamId, setter)} /> + {editingStream != null && updateStreamParams(editingFile, editingStreamId, setter)} />} Tags - +
); }); -function onInfoClick(json, title) { +function onInfoClick(json: unknown, title: string) { showJson5Dialog({ title, json }); } -const Stream = memo(({ filePath, stream, onToggle, batchSetCopyStreamIds, copyStream, fileDuration, setEditingStream, onExtractStreamPress, paramsByStreamId, updateStreamParams, formatTimecode }) => { +const Stream = memo(({ filePath, stream, onToggle, batchSetCopyStreamIds, copyStream, fileDuration, setEditingStream, onExtractStreamPress, paramsByStreamId, updateStreamParams, formatTimecode }: { + filePath: string, stream: FFprobeStream, onToggle: (a: number) => void, batchSetCopyStreamIds: (filter: (a: FFprobeStream) => boolean, enabled: boolean) => void, copyStream: boolean, fileDuration: number | undefined, setEditingStream: (a: EditingStream) => void, onExtractStreamPress?: () => void, paramsByStreamId: ParamsByStreamId, updateStreamParams: UpdateStreamParams, formatTimecode: FormatTimecode, +}) => { const { t } = useTranslation(); const effectiveDisposition = useMemo(() => getStreamEffectiveDisposition(paramsByStreamId, filePath, stream), [filePath, paramsByStreamId, stream]); - const bitrate = parseInt(stream.bit_rate, 10); + const bitrate = parseInt(stream.bit_rate!, 10); const streamDuration = parseInt(stream.duration, 10); const duration = !Number.isNaN(streamDuration) ? streamDuration : fileDuration; - let Icon; + let Icon: typeof FaBan; let codecTypeHuman; // eslint-disable-next-line unicorn/prefer-switch if (stream.codec_type === 'audio') { @@ -170,15 +191,18 @@ const Stream = memo(({ filePath, stream, onToggle, batchSetCopyStreamIds, copySt const onClick = () => onToggle && onToggle(stream.index); - const onDispositionChange = useCallback((e) => { - let newDisposition; + const onDispositionChange = useCallback>((e) => { + let newDisposition: string; if (dispositionOptions.includes(e.target.value)) { newDisposition = e.target.value; } else if (e.target.value === deleteDispositionValue) { newDisposition = deleteDispositionValue; // needs a separate value (not a real disposition) } // else unchanged (undefined) - updateStreamParams(filePath, stream.index, (params) => params.set('disposition', newDisposition)); + updateStreamParams(filePath, stream.index, (params) => { + // eslint-disable-next-line no-param-reassign + params.disposition = newDisposition; + }); }, [filePath, updateStreamParams, stream.index]); const codecTag = stream.codec_tag !== '0x0000' && stream.codec_tag_string; @@ -191,7 +215,7 @@ const Stream = memo(({ filePath, stream, onToggle, batchSetCopyStreamIds, copySt {stream.codec_name} {codecTag} - {!Number.isNaN(duration) && `${formatTimecode({ seconds: duration, shorten: true })}`} + {duration != null && !Number.isNaN(duration) && `${formatTimecode({ seconds: duration, shorten: true })}`} {stream.nb_frames != null ?
{stream.nb_frames}f
: null} {!Number.isNaN(bitrate) && (stream.codec_type === 'audio' ? `${Math.round(bitrate / 1000)} kbps` : prettyBytes(bitrate, { bits: true }))} @@ -247,7 +271,9 @@ const Stream = memo(({ filePath, stream, onToggle, batchSetCopyStreamIds, copySt ); }); -const FileHeading = ({ path, formatData, chapters, onTrashClick, onEditClick, setCopyAllStreams, onExtractAllStreamsPress }) => { +function FileHeading({ path, formatData, chapters, onTrashClick, onEditClick, setCopyAllStreams, onExtractAllStreamsPress }: { + path: string, formatData: FFprobeFormat | undefined, chapters?: FFprobeChapter[] | undefined, onTrashClick?: (() => void) | undefined, onEditClick?: (() => void) | undefined, setCopyAllStreams: (a: boolean) => void, onExtractAllStreamsPress?: () => Promise, +}) { const { t } = useTranslation(); return ( @@ -265,11 +291,11 @@ const FileHeading = ({ path, formatData, chapters, onTrashClick, onEditClick, se {onExtractAllStreamsPress && } ); -}; +} -const thStyle = { borderBottom: '1px solid var(--gray6)', paddingBottom: '.5em' }; +const thStyle: CSSProperties = { borderBottom: '1px solid var(--gray6)', paddingBottom: '.5em' }; -const Thead = () => { +function Thead() { const { t } = useTranslation(); return ( @@ -286,31 +312,50 @@ const Thead = () => { ); -}; +} -const tableStyle = { fontSize: 14, width: '100%', borderCollapse: 'collapse' }; -const fileStyle = { margin: '1.5em 1em 1.5em 1em', padding: 5, overflowX: 'auto' }; +const tableStyle: CSSProperties = { fontSize: 14, width: '100%', borderCollapse: 'collapse' }; +const fileStyle: CSSProperties = { margin: '1.5em 1em 1.5em 1em', padding: 5, overflowX: 'auto' }; -const StreamsSelector = memo(({ - mainFilePath, mainFileFormatData, mainFileStreams, mainFileChapters, isCopyingStreamId, toggleCopyStreamId, - setCopyStreamIdsForPath, onExtractStreamPress, onExtractAllStreamsPress, allFilesMeta, externalFilesMeta, setExternalFilesMeta, - showAddStreamSourceDialog, shortestFlag, setShortestFlag, nonCopiedExtraStreams, - customTagsByFile, setCustomTagsByFile, paramsByStreamId, updateStreamParams, - formatTimecode, -}) => { - const [editingFile, setEditingFile] = useState(); - const [editingStream, setEditingStream] = useState(); + +function StreamsSelector({ + mainFilePath, mainFileFormatData, mainFileStreams, mainFileChapters, isCopyingStreamId, toggleCopyStreamId, setCopyStreamIdsForPath, onExtractStreamPress, onExtractAllStreamsPress, allFilesMeta, externalFilesMeta, setExternalFilesMeta, showAddStreamSourceDialog, shortestFlag, setShortestFlag, nonCopiedExtraStreams, customTagsByFile, setCustomTagsByFile, paramsByStreamId, updateStreamParams, formatTimecode, +}: { + mainFilePath: string, + mainFileFormatData: FFprobeFormat | undefined, + mainFileStreams: FFprobeStream[], + mainFileChapters: FFprobeChapter[] | undefined, + isCopyingStreamId: (path: string | undefined, streamId: number) => boolean, + toggleCopyStreamId: (path: string, index: number) => void, + setCopyStreamIdsForPath: (path: string, cb: (a: Record) => Record) => void, + onExtractStreamPress: (index: number) => void, + onExtractAllStreamsPress: () => Promise, + allFilesMeta: FilesMeta, + externalFilesMeta: FilesMeta, + setExternalFilesMeta: Dispatch>, + showAddStreamSourceDialog: () => Promise, + shortestFlag: boolean, + setShortestFlag: Dispatch>, + nonCopiedExtraStreams: FFprobeStream[], + customTagsByFile: CustomTagsByFile, + setCustomTagsByFile: Dispatch>, + paramsByStreamId: ParamsByStreamId, + updateStreamParams: UpdateStreamParams, + formatTimecode: FormatTimecode, +}) { + const [editingFile, setEditingFile] = useState(); + const [editingStream, setEditingStream] = useState(); + const [editingTag, setEditingTag] = useState(); const { t } = useTranslation(); - const [editingTag, setEditingTag] = useState(); - function getFormatDuration(formatData) { + function getFormatDuration(formatData: FFprobeFormat | undefined) { if (!formatData || !formatData.duration) return undefined; - const parsed = parseFloat(formatData.duration, 10); + const parsed = parseFloat(formatData.duration); if (Number.isNaN(parsed)) return undefined; return parsed; } - async function removeFile(path) { + function removeFile(path: string) { setCopyStreamIdsForPath(path, () => ({})); setExternalFilesMeta((old) => { // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -319,7 +364,7 @@ const StreamsSelector = memo(({ }); } - async function batchSetCopyStreamIdsForPath(path, streams, filter, enabled) { + function batchSetCopyStreamIdsForPath(path: string, streams: FFprobeStream[], filter: (a: FFprobeStream) => boolean, enabled: boolean) { setCopyStreamIdsForPath(path, (old) => { const ret = { ...old }; // eslint-disable-next-line unicorn/no-array-callback-reference @@ -330,7 +375,7 @@ const StreamsSelector = memo(({ }); } - async function setCopyAllStreamsForPath(path, enabled) { + function setCopyAllStreamsForPath(path: string, enabled: boolean) { setCopyStreamIdsForPath(path, (old) => Object.fromEntries(Object.entries(old).map(([streamId]) => [streamId, enabled]))); } @@ -354,7 +399,7 @@ const StreamsSelector = memo(({ stream={stream} copyStream={isCopyingStreamId(mainFilePath, stream.index)} onToggle={(streamId) => toggleCopyStreamId(mainFilePath, streamId)} - batchSetCopyStreamIds={(filter, enabled) => batchSetCopyStreamIdsForPath(mainFilePath, mainFileStreams, filter, enabled)} + batchSetCopyStreamIds={(filter: (a: FFprobeStream) => boolean, enabled: boolean) => batchSetCopyStreamIdsForPath(mainFilePath, mainFileStreams, filter, enabled)} setEditingStream={setEditingStream} fileDuration={getFormatDuration(mainFileFormatData)} onExtractStreamPress={() => onExtractStreamPress(stream.index)} @@ -381,7 +426,7 @@ const StreamsSelector = memo(({ stream={stream} copyStream={isCopyingStreamId(path, stream.index)} onToggle={(streamId) => toggleCopyStreamId(path, streamId)} - batchSetCopyStreamIds={(filter, enabled) => batchSetCopyStreamIdsForPath(path, streams, filter, enabled)} + batchSetCopyStreamIds={(filter: (a: FFprobeStream) => boolean, enabled: boolean) => batchSetCopyStreamIdsForPath(path, streams, filter, enabled)} setEditingStream={setEditingStream} fileDuration={getFormatDuration(formatData)} paramsByStreamId={paramsByStreamId} @@ -425,10 +470,12 @@ const StreamsSelector = memo(({ isShown={editingFile != null} hasCancel={false} confirmLabel={t('Done')} - onCloseComplete={() => setEditingFile()} + onCloseComplete={() => setEditingFile(undefined)} isConfirmDisabled={editingTag != null} > - +
+ {editingFile != null && } +
{editingStream != null && ( @@ -442,6 +489,6 @@ const StreamsSelector = memo(({ )} ); -}); +} -export default StreamsSelector; +export default memo(StreamsSelector); diff --git a/src/renderer/src/dialogs/index.tsx b/src/renderer/src/dialogs/index.tsx index 8a72742..51f4ea9 100644 --- a/src/renderer/src/dialogs/index.tsx +++ b/src/renderer/src/dialogs/index.tsx @@ -560,7 +560,7 @@ export async function selectSegmentsByTagDialog() { return { tagName: value1, tagValue: value2 }; } -export function showJson5Dialog({ title, json }) { +export function showJson5Dialog({ title, json }: { title: string, json: unknown }) { const html = ( {JSON5.stringify(json, null, 2)} diff --git a/src/renderer/src/hooks/useFfmpegOperations.ts b/src/renderer/src/hooks/useFfmpegOperations.ts index 9f92de4..559c2e0 100644 --- a/src/renderer/src/hooks/useFfmpegOperations.ts +++ b/src/renderer/src/hooks/useFfmpegOperations.ts @@ -11,6 +11,7 @@ import { getSmartCutParams } from '../smartcut'; import { isDurationValid } from '../segments'; import { FFprobeStream } from '../../../../ffprobe'; import { Html5ifyMode } from '../../../../types'; +import { ParamsByStreamId } from '../types'; const { join, resolve, dirname } = window.require('path'); const { pathExists } = window.require('fs-extra'); @@ -181,6 +182,9 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea const losslessCutSingle = useCallback(async ({ keyframeCut: ssBeforeInput, avoidNegativeTs, copyFileStreams, cutFrom, cutTo, chaptersPath, onProgress, outPath, videoDuration, rotation, allFilesMeta, outFormat, appendFfmpegCommandLog, shortestFlag, ffmpegExperimental, preserveMovData, movFastStart, customTagsByFile, paramsByStreamId, videoTimebase, detectedFps, + }: { + keyframeCut: boolean, avoidNegativeTs: boolean, copyFileStreams, cutFrom, cutTo, chaptersPath, onProgress, outPath, + videoDuration, rotation, allFilesMeta, outFormat, appendFfmpegCommandLog, shortestFlag, ffmpegExperimental, preserveMovData, movFastStart, customTagsByFile, paramsByStreamId: ParamsByStreamId, videoTimebase, detectedFps, }) => { if (await shouldSkipExistingFile(outPath)) return; @@ -227,7 +231,7 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea // This function tries to calculate the output stream index needed for -metadata:s:x and -disposition:x arguments // It is based on the assumption that copyFileStreamsFiltered contains the order of the input files (and their respective streams orders) sent to ffmpeg, to hopefully calculate the same output stream index values that ffmpeg does internally. // It also takes into account previously added files that have been removed and disabled streams. - function mapInputStreamIndexToOutputIndex(inputFilePath, inputFileStreamIndex) { + function mapInputStreamIndexToOutputIndex(inputFilePath: string, inputFileStreamIndex: number) { let streamCount = 0; // Count copied streams of all files until this input file const foundFile = copyFileStreamsFiltered.find(({ path: path2, streamIds }) => { @@ -254,24 +258,24 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea const ret: string[] = []; for (const [fileId, fileParams] of paramsByStreamId.entries()) { for (const [streamId, streamParams] of fileParams.entries()) { - const outputIndex = mapInputStreamIndexToOutputIndex(fileId, parseInt(streamId, 10)); + const outputIndex = mapInputStreamIndexToOutputIndex(fileId, streamId); if (outputIndex != null) { - const disposition = streamParams.get('disposition'); + const { disposition } = streamParams; if (disposition != null) { // "0" means delete the disposition for this stream const dispositionArg = disposition === deleteDispositionValue ? '0' : disposition; ret.push(`-disposition:${outputIndex}`, String(dispositionArg)); } - if (streamParams.get('bsfH264Mp4toannexb')) { + if (streamParams.bsfH264Mp4toannexb) { ret.push(`-bsf:${outputIndex}`, String('h264_mp4toannexb')); } - if (streamParams.get('bsfHevcMp4toannexb')) { + if (streamParams.bsfHevcMp4toannexb) { ret.push(`-bsf:${outputIndex}`, String('hevc_mp4toannexb')); } // custom stream metadata tags - const customTags = streamParams.get('customTags'); + const { customTags } = streamParams; if (customTags != null) { for (const [tag, value] of Object.entries(customTags)) { ret.push(`-metadata:s:${outputIndex}`, `${tag}=${value}`); diff --git a/src/renderer/src/hooks/useWaveform.ts b/src/renderer/src/hooks/useWaveform.ts index 1fbe0c4..b702e1a 100644 --- a/src/renderer/src/hooks/useWaveform.ts +++ b/src/renderer/src/hooks/useWaveform.ts @@ -11,8 +11,8 @@ import { FFprobeStream } from '../../../../ffprobe'; const maxWaveforms = 100; // const maxWaveforms = 3; // testing -export default ({ darkMode, filePath, relevantTime, durationSafe, waveformEnabled, audioStream, ffmpegExtractWindow }: { - darkMode: boolean, filePath: string | undefined, relevantTime: number, durationSafe: number, waveformEnabled: boolean, audioStream: FFprobeStream | undefined, ffmpegExtractWindow: number, +export default ({ darkMode, filePath, relevantTime, duration, waveformEnabled, audioStream, ffmpegExtractWindow }: { + darkMode: boolean, filePath: string | undefined, relevantTime: number, duration: number | undefined, waveformEnabled: boolean, audioStream: FFprobeStream | undefined, ffmpegExtractWindow: number, }) => { const creatingWaveformPromise = useRef>(); const [waveforms, setWaveforms] = useState([]); @@ -30,7 +30,7 @@ export default ({ darkMode, filePath, relevantTime, durationSafe, waveformEnable }, [filePath, audioStream, setWaveforms]); const waveformStartTime = Math.floor(relevantTime / ffmpegExtractWindow) * ffmpegExtractWindow; - const safeExtractDuration = Math.min(waveformStartTime + ffmpegExtractWindow, durationSafe) - waveformStartTime; + const safeExtractDuration = duration != null ? Math.min(waveformStartTime + ffmpegExtractWindow, duration) - waveformStartTime : undefined; const waveformStartTimeThrottled = useThrottle(waveformStartTime, 1000); @@ -39,7 +39,7 @@ export default ({ darkMode, filePath, relevantTime, durationSafe, waveformEnable (async () => { const alreadyHaveWaveformAtTime = (waveformsRef.current ?? []).some((waveform) => waveform.from === waveformStartTimeThrottled); - const shouldRun = !!filePath && audioStream && waveformEnabled && !alreadyHaveWaveformAtTime && !creatingWaveformPromise.current; + const shouldRun = !!filePath && safeExtractDuration != null && audioStream && waveformEnabled && !alreadyHaveWaveformAtTime && !creatingWaveformPromise.current; if (!shouldRun) return; try { @@ -72,9 +72,13 @@ export default ({ darkMode, filePath, relevantTime, durationSafe, waveformEnable if (w.from !== waveformStartTimeThrottled) { return w; } + + // if we don't do this, we get Failed to construct 'Blob': The provided ArrayBufferView value must not be resizable. + const buffer2 = Buffer.allocUnsafe(buffer.length); + buffer.copy(buffer2); return { ...w, - url: URL.createObjectURL(new Blob([buffer], { type: 'image/png' })), + url: URL.createObjectURL(new Blob([buffer2], { type: 'image/png' })), }; })); } catch (err) { diff --git a/src/renderer/src/types.ts b/src/renderer/src/types.ts index d9b9561..bd80486 100644 --- a/src/renderer/src/types.ts +++ b/src/renderer/src/types.ts @@ -1,5 +1,6 @@ import type { MenuItem, MenuItemConstructorOptions } from 'electron'; import { z } from 'zod'; +import { FFprobeChapter, FFprobeFormat, FFprobeStream } from '../../../ffprobe'; export interface ChromiumHTMLVideoElement extends HTMLVideoElement { @@ -96,3 +97,19 @@ export type UpdateSegAtIndex = (index: number, newProps: Partial) export type ContextMenuTemplate = (MenuItemConstructorOptions | MenuItem)[]; export type ExportMode = 'segments_to_chapters' | 'merge' | 'merge+separate' | 'separate'; + +export type FilesMeta = Record + +export type CustomTagsByFile = Record>; + +export interface StreamParams { + customTags?: Record, + disposition?: string, + bsfH264Mp4toannexb?: boolean, + bsfHevcMp4toannexb?: boolean, +} +export type ParamsByStreamId = Map>;