kopia lustrzana https://github.com/mifi/lossless-cut
fixes
fix stream tags type fix waveform crash and bug with initial renderpull/1982/head
rodzic
852a7989ba
commit
666a1c5bd4
|
@ -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,
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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<number>();
|
||||
const [startTimeOffset, setStartTimeOffset] = useState(0);
|
||||
const [filePath, setFilePath] = useState<string>();
|
||||
const [externalFilesMeta, setExternalFilesMeta] = useState<Record<string, { streams: FFprobeStream[], formatData: FFprobeFormat, chapters: FFprobeChapter[] }>>({});
|
||||
const [customTagsByFile, setCustomTagsByFile] = useState({});
|
||||
const [paramsByStreamId, setParamsByStreamId] = useState(new Map());
|
||||
const [externalFilesMeta, setExternalFilesMeta] = useState<FilesMeta>({});
|
||||
const [customTagsByFile, setCustomTagsByFile] = useState<CustomTagsByFile>({});
|
||||
const [paramsByStreamId, setParamsByStreamId] = useState<ParamsByStreamId>(new Map());
|
||||
const [detectedFps, setDetectedFps] = useState<number>();
|
||||
const [mainFileMeta, setMainFileMeta] = useState<{ streams: FFprobeStream[], formatData: FFprobeFormat, chapters: FFprobeChapter[] }>();
|
||||
const [copyStreamIdsByFile, setCopyStreamIdsByFile] = useState<Record<string, Record<string, boolean>>>({});
|
||||
|
@ -253,7 +253,7 @@ function App() {
|
|||
setFfmpegCommandLog((old) => [...old, { command, time: new Date() }]);
|
||||
}
|
||||
|
||||
const setCopyStreamIdsForPath = useCallback((path, cb) => {
|
||||
const setCopyStreamIdsForPath = useCallback<Parameters<typeof StreamsSelector>[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<Parameters<typeof StreamsSelector>[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() {
|
|||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{showRightBar && isFileOpened && (
|
||||
{showRightBar && isFileOpened && filePath != null && (
|
||||
<SegmentList
|
||||
width={rightBarWidth}
|
||||
currentSegIndex={currentSegIndexSafe}
|
||||
|
@ -2726,9 +2731,8 @@ function App() {
|
|||
<ExportConfirm areWeCutting={areWeCutting} nonFilteredSegmentsOrInverse={nonFilteredSegmentsOrInverse} selectedSegments={selectedSegmentsOrInverse} segmentsToExport={segmentsToExport} willMerge={willMerge} visible={exportConfirmVisible} onClosePress={closeExportConfirm} onExportConfirm={onExportConfirm} renderOutFmt={renderOutFmt} outputDir={outputDir} numStreamsTotal={numStreamsTotal} numStreamsToCopy={numStreamsToCopy} onShowStreamsSelectorClick={handleShowStreamsSelectorClick} outFormat={fileFormat} setOutSegTemplate={setOutSegTemplate} outSegTemplate={outSegTemplateOrDefault} generateOutSegFileNames={generateOutSegFileNames} currentSegIndexSafe={currentSegIndexSafe} mainCopiedThumbnailStreams={mainCopiedThumbnailStreams} needSmartCut={needSmartCut} mergedOutFileName={mergedOutFileName} setMergedOutFileName={setMergedOutFileName} />
|
||||
|
||||
<Sheet visible={streamsSelectorShown} onClosePress={() => setStreamsSelectorShown(false)} maxWidth={1000}>
|
||||
{mainStreams && (
|
||||
{mainStreams && filePath != null && (
|
||||
<StreamsSelector
|
||||
// @ts-expect-error todo
|
||||
mainFilePath={filePath}
|
||||
mainFileFormatData={mainFileFormatData}
|
||||
mainFileChapters={mainFileChapters}
|
||||
|
@ -2742,7 +2746,6 @@ function App() {
|
|||
setCopyStreamIdsForPath={setCopyStreamIdsForPath}
|
||||
onExtractAllStreamsPress={extractAllStreams}
|
||||
onExtractStreamPress={extractSingleStream}
|
||||
areWeCutting={areWeCutting}
|
||||
shortestFlag={shortestFlag}
|
||||
setShortestFlag={setShortestFlag}
|
||||
nonCopiedExtraStreams={nonCopiedExtraStreams}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { memo, useState, useMemo, useCallback } from 'react';
|
||||
import { memo, useState, useMemo, useCallback, Dispatch, SetStateAction, CSSProperties, ReactNode, ChangeEventHandler } from 'react';
|
||||
|
||||
import { FaImage, FaCheckCircle, FaPaperclip, FaVideo, FaVideoSlash, FaFileImport, FaVolumeUp, FaVolumeMute, FaBan, FaFileExport } from 'react-icons/fa';
|
||||
import { GoFileBinary } from 'react-icons/go';
|
||||
|
@ -14,24 +14,34 @@ import { getStreamFps } from './ffmpeg';
|
|||
import { deleteDispositionValue } from './util';
|
||||
import { getActiveDisposition, attachedPicDisposition } from './util/streams';
|
||||
import TagEditor from './components/TagEditor';
|
||||
import { FFprobeChapter, FFprobeFormat, FFprobeStream } from '../../../ffprobe';
|
||||
import { CustomTagsByFile, FilesMeta, FormatTimecode, ParamsByStreamId, StreamParams } from './types';
|
||||
|
||||
|
||||
const dispositionOptions = ['default', 'dub', 'original', 'comment', 'lyrics', 'karaoke', 'forced', 'hearing_impaired', 'visual_impaired', 'clean_effects', 'attached_pic', 'captions', 'descriptions', 'dependent', 'metadata'];
|
||||
const unchangedDispositionValue = 'llc_disposition_unchanged';
|
||||
|
||||
type UpdateStreamParams = (fileId: string, streamId: number, setter: (a: StreamParams) => 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<SetStateAction<CustomTagsByFile>>, 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<string, string>) => {
|
||||
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 <TagEditor existingTags={existingTags} customTags={customTags} editingTag={editingTag} setEditingTag={setEditingTag} onTagChange={onTagChange} onTagReset={onTagReset} addTagTitle={t('Add metadata')} addTagText={t('Enter metadata key')} />;
|
||||
return <TagEditor existingTags={existingTags} customTags={customTags} editingTag={editingTag} setEditingTag={setEditingTag} onTagsChange={onTagsChange} onTagReset={onTagReset} addTagTitle={t('Add metadata')} addTagText={t('Enter metadata key')} />;
|
||||
});
|
||||
|
||||
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(
|
||||
<Checkbox key="bsfH264Mp4toannexb" checked={!!streamParams.get('bsfH264Mp4toannexb')} label={t('Enable "{{filterName}}" bitstream filter.', { filterName: 'h264_mp4toannexb' })} onChange={(e) => updateStreamParams((params) => params.set('bsfH264Mp4toannexb', e.target.checked))} />,
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
<Checkbox key="bsfH264Mp4toannexb" checked={!!streamParams.bsfH264Mp4toannexb} label={t('Enable "{{filterName}}" bitstream filter.', { filterName: 'h264_mp4toannexb' })} onChange={(e) => updateStreamParams((params) => { params.bsfH264Mp4toannexb = e.target.checked; })} />,
|
||||
);
|
||||
}
|
||||
if (stream.codec_name === 'hevc') {
|
||||
ui.push(
|
||||
<Checkbox key="bsfHevcMp4toannexb" checked={!!streamParams.get('bsfHevcMp4toannexb')} label={t('Enable "{{filterName}}" bitstream filter.', { filterName: 'hevc_mp4toannexb' })} onChange={(e) => updateStreamParams((params) => params.set('bsfHevcMp4toannexb', e.target.checked))} />,
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
<Checkbox key="bsfHevcMp4toannexb" checked={!!streamParams.bsfHevcMp4toannexb} label={t('Enable "{{filterName}}" bitstream filter.', { filterName: 'hevc_mp4toannexb' })} onChange={(e) => updateStreamParams((params) => { params.bsfHevcMp4toannexb = e.target.checked; })} />,
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -76,34 +90,39 @@ const StreamParametersEditor = ({ stream, streamParams, updateStreamParams }) =>
|
|||
: t('No editable parameters for this stream.')}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
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<SetStateAction<EditingStream | undefined>>, allFilesMeta: FilesMeta, paramsByStreamId: ParamsByStreamId, updateStreamParams: UpdateStreamParams,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [editingTag, setEditingTag] = useState();
|
||||
const [editingTag, setEditingTag] = useState<string>();
|
||||
|
||||
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<string, string>) => {
|
||||
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)}
|
||||
>
|
||||
<div style={{ color: 'black' }}>
|
||||
<Heading>Parameters</Heading>
|
||||
<StreamParametersEditor stream={editingStream} streamParams={streamParams} updateStreamParams={(setter) => updateStreamParams(editingFile, editingStreamId, setter)} />
|
||||
{editingStream != null && <StreamParametersEditor stream={editingStream} streamParams={streamParams} updateStreamParams={(setter) => updateStreamParams(editingFile, editingStreamId, setter)} />}
|
||||
<Heading>Tags</Heading>
|
||||
<TagEditor existingTags={existingTags} customTags={customTags} editingTag={editingTag} setEditingTag={setEditingTag} onTagChange={onTagChange} onTagReset={onTagReset} addTagTitle={t('Add metadata')} addTagText={t('Enter metadata key')} />
|
||||
<TagEditor existingTags={existingTags} customTags={customTags} editingTag={editingTag} setEditingTag={setEditingTag} onTagsChange={onTagsChange} onTagReset={onTagReset} addTagTitle={t('Add metadata')} addTagText={t('Enter metadata key')} />
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
});
|
||||
|
||||
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<ChangeEventHandler<HTMLSelectElement>>((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
|
|||
</td>
|
||||
<td style={{ maxWidth: '3em', overflow: 'hidden' }} title={stream.codec_name}>{stream.codec_name} {codecTag}</td>
|
||||
<td>
|
||||
{!Number.isNaN(duration) && `${formatTimecode({ seconds: duration, shorten: true })}`}
|
||||
{duration != null && !Number.isNaN(duration) && `${formatTimecode({ seconds: duration, shorten: true })}`}
|
||||
{stream.nb_frames != null ? <div>{stream.nb_frames}f</div> : null}
|
||||
</td>
|
||||
<td>{!Number.isNaN(bitrate) && (stream.codec_type === 'audio' ? `${Math.round(bitrate / 1000)} kbps` : prettyBytes(bitrate, { bits: true }))}</td>
|
||||
|
@ -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<void>,
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
|
@ -265,11 +291,11 @@ const FileHeading = ({ path, formatData, chapters, onTrashClick, onEditClick, se
|
|||
{onExtractAllStreamsPress && <IconButton iconSize={16} title={t('Export each track as individual files')} icon={ForkIcon} onClick={onExtractAllStreamsPress} appearance="minimal" />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
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 (
|
||||
<thead style={{ color: 'var(--gray12)', textAlign: 'left', fontSize: '.9em' }}>
|
||||
|
@ -286,31 +312,50 @@ const Thead = () => {
|
|||
</tr>
|
||||
</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<string, boolean>) => Record<string, boolean>) => void,
|
||||
onExtractStreamPress: (index: number) => void,
|
||||
onExtractAllStreamsPress: () => Promise<void>,
|
||||
allFilesMeta: FilesMeta,
|
||||
externalFilesMeta: FilesMeta,
|
||||
setExternalFilesMeta: Dispatch<SetStateAction<FilesMeta>>,
|
||||
showAddStreamSourceDialog: () => Promise<void>,
|
||||
shortestFlag: boolean,
|
||||
setShortestFlag: Dispatch<SetStateAction<boolean>>,
|
||||
nonCopiedExtraStreams: FFprobeStream[],
|
||||
customTagsByFile: CustomTagsByFile,
|
||||
setCustomTagsByFile: Dispatch<SetStateAction<CustomTagsByFile>>,
|
||||
paramsByStreamId: ParamsByStreamId,
|
||||
updateStreamParams: UpdateStreamParams,
|
||||
formatTimecode: FormatTimecode,
|
||||
}) {
|
||||
const [editingFile, setEditingFile] = useState<string>();
|
||||
const [editingStream, setEditingStream] = useState<EditingStream>();
|
||||
const [editingTag, setEditingTag] = useState<string>();
|
||||
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}
|
||||
>
|
||||
<EditFileDialog editingFile={editingFile} editingTag={editingTag} setEditingTag={setEditingTag} allFilesMeta={allFilesMeta} customTagsByFile={customTagsByFile} setCustomTagsByFile={setCustomTagsByFile} />
|
||||
<div style={{ color: 'black' }}>
|
||||
{editingFile != null && <EditFileDialog editingFile={editingFile} editingTag={editingTag} setEditingTag={setEditingTag} allFilesMeta={allFilesMeta} customTagsByFile={customTagsByFile} setCustomTagsByFile={setCustomTagsByFile} />}
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
{editingStream != null && (
|
||||
|
@ -442,6 +489,6 @@ const StreamsSelector = memo(({
|
|||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export default StreamsSelector;
|
||||
export default memo(StreamsSelector);
|
|
@ -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 = (
|
||||
<SyntaxHighlighter language="javascript" style={syntaxStyle} customStyle={{ textAlign: 'left', maxHeight: 300, overflowY: 'auto', fontSize: 14 }}>
|
||||
{JSON5.stringify(json, null, 2)}
|
||||
|
|
|
@ -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}`);
|
||||
|
|
|
@ -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<Promise<unknown>>();
|
||||
const [waveforms, setWaveforms] = useState<RenderableWaveform[]>([]);
|
||||
|
@ -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) {
|
||||
|
|
|
@ -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<StateSegment>)
|
|||
export type ContextMenuTemplate = (MenuItemConstructorOptions | MenuItem)[];
|
||||
|
||||
export type ExportMode = 'segments_to_chapters' | 'merge' | 'merge+separate' | 'separate';
|
||||
|
||||
export type FilesMeta = Record<string, {
|
||||
streams: FFprobeStream[];
|
||||
formatData: FFprobeFormat;
|
||||
chapters: FFprobeChapter[];
|
||||
}>
|
||||
|
||||
export type CustomTagsByFile = Record<string, Record<string, string>>;
|
||||
|
||||
export interface StreamParams {
|
||||
customTags?: Record<string, string>,
|
||||
disposition?: string,
|
||||
bsfH264Mp4toannexb?: boolean,
|
||||
bsfHevcMp4toannexb?: boolean,
|
||||
}
|
||||
export type ParamsByStreamId = Map<string, Map<number, StreamParams>>;
|
||||
|
|
Ładowanie…
Reference in New Issue