mirror of
https://github.com/mifi/lossless-cut.git
synced 2024-11-22 18:32:34 +01:00
option to write segment labels as chapters #318
This commit is contained in:
parent
44cd0cd810
commit
5f6e9f33dc
@ -23,6 +23,7 @@ const defaults = {
|
||||
avoidNegativeTs: 'make_zero',
|
||||
hideNotifications: undefined,
|
||||
autoLoadTimecode: false,
|
||||
segmentsToChapters: false,
|
||||
},
|
||||
};
|
||||
|
||||
|
11
src/App.jsx
11
src/App.jsx
@ -199,6 +199,8 @@ const App = memo(() => {
|
||||
useEffect(() => safeSetConfig('autoDeleteMergedSegments', autoDeleteMergedSegments), [autoDeleteMergedSegments]);
|
||||
const [exportConfirmEnabled, setExportConfirmEnabled] = useState(configStore.get('exportConfirmEnabled'));
|
||||
useEffect(() => safeSetConfig('exportConfirmEnabled', exportConfirmEnabled), [exportConfirmEnabled]);
|
||||
const [segmentsToChapters, setSegmentsToChapters] = useState(configStore.get('segmentsToChapters'));
|
||||
useEffect(() => safeSetConfig('segmentsToChapters', segmentsToChapters), [segmentsToChapters]);
|
||||
|
||||
useEffect(() => {
|
||||
i18n.changeLanguage(language || fallbackLng).catch(console.error);
|
||||
@ -235,6 +237,8 @@ const App = memo(() => {
|
||||
|
||||
const toggleExportConfirmEnabled = useCallback(() => setExportConfirmEnabled((v) => !v), []);
|
||||
|
||||
const toggleSegmentsToChapters = useCallback(() => setSegmentsToChapters((v) => !v), []);
|
||||
|
||||
const toggleKeyframesEnabled = useCallback(() => {
|
||||
setKeyframesEnabled((old) => {
|
||||
const enabled = !old;
|
||||
@ -1011,6 +1015,8 @@ const App = memo(() => {
|
||||
setCutProgress(0);
|
||||
setWorking(i18n.t('Merging'));
|
||||
|
||||
const chapterNames = segmentsToChapters && !invertCutSegments && outSegments ? outSegments.map((s) => s.name) : undefined;
|
||||
|
||||
await autoMergeSegments({
|
||||
customOutDir,
|
||||
sourceFile: filePath,
|
||||
@ -1020,6 +1026,7 @@ const App = memo(() => {
|
||||
ffmpegExperimental,
|
||||
preserveMovData,
|
||||
onProgress: setCutProgress,
|
||||
chapterNames,
|
||||
autoDeleteMergedSegments,
|
||||
});
|
||||
}
|
||||
@ -1051,7 +1058,7 @@ const App = memo(() => {
|
||||
setWorking();
|
||||
setCutProgress();
|
||||
}
|
||||
}, [autoMerge, copyFileStreams, customOutDir, duration, effectiveRotation, exportExtraStreams, ffmpegExperimental, fileFormat, fileFormatData, filePath, handleCutFailed, isCustomFormatSelected, isRotationSet, keyframeCut, mainStreams, nonCopiedExtraStreams, outSegments, outputDir, shortestFlag, working, preserveMovData, avoidNegativeTs, numStreamsToCopy, hideAllNotifications, currentSegIndexSafe, autoDeleteMergedSegments]);
|
||||
}, [autoMerge, copyFileStreams, customOutDir, duration, effectiveRotation, exportExtraStreams, ffmpegExperimental, fileFormat, fileFormatData, filePath, handleCutFailed, isCustomFormatSelected, isRotationSet, keyframeCut, mainStreams, nonCopiedExtraStreams, outSegments, outputDir, shortestFlag, working, preserveMovData, avoidNegativeTs, numStreamsToCopy, hideAllNotifications, currentSegIndexSafe, invertCutSegments, autoDeleteMergedSegments, segmentsToChapters]);
|
||||
|
||||
const onExportPress = useCallback(async () => {
|
||||
if (working || !filePath) return;
|
||||
@ -2209,7 +2216,7 @@ const App = memo(() => {
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<ExportConfirm autoMerge={autoMerge} toggleAutoMerge={toggleAutoMerge} areWeCutting={areWeCutting} outSegments={outSegments} visible={exportConfirmVisible} onClosePress={closeExportConfirm} onExportConfirm={onExportConfirm} keyframeCut={keyframeCut} toggleKeyframeCut={toggleKeyframeCut} renderOutFmt={renderOutFmt} preserveMovData={preserveMovData} togglePreserveMovData={togglePreserveMovData} avoidNegativeTs={avoidNegativeTs} setAvoidNegativeTs={setAvoidNegativeTs} changeOutDir={changeOutDir} outputDir={outputDir} numStreamsTotal={numStreamsTotal} numStreamsToCopy={numStreamsToCopy} setStreamsSelectorShown={setStreamsSelectorShown} currentSegIndex={currentSegIndexSafe} invertCutSegments={invertCutSegments} exportConfirmEnabled={exportConfirmEnabled} toggleExportConfirmEnabled={toggleExportConfirmEnabled} />
|
||||
<ExportConfirm autoMerge={autoMerge} toggleAutoMerge={toggleAutoMerge} areWeCutting={areWeCutting} outSegments={outSegments} visible={exportConfirmVisible} onClosePress={closeExportConfirm} onExportConfirm={onExportConfirm} keyframeCut={keyframeCut} toggleKeyframeCut={toggleKeyframeCut} renderOutFmt={renderOutFmt} preserveMovData={preserveMovData} togglePreserveMovData={togglePreserveMovData} avoidNegativeTs={avoidNegativeTs} setAvoidNegativeTs={setAvoidNegativeTs} changeOutDir={changeOutDir} outputDir={outputDir} numStreamsTotal={numStreamsTotal} numStreamsToCopy={numStreamsToCopy} setStreamsSelectorShown={setStreamsSelectorShown} currentSegIndex={currentSegIndexSafe} invertCutSegments={invertCutSegments} exportConfirmEnabled={exportConfirmEnabled} toggleExportConfirmEnabled={toggleExportConfirmEnabled} segmentsToChapters={segmentsToChapters} toggleSegmentsToChapters={toggleSegmentsToChapters} />
|
||||
|
||||
<HelpSheet
|
||||
visible={helpVisible}
|
||||
|
@ -40,7 +40,7 @@ const ExportConfirm = memo(({
|
||||
autoMerge, areWeCutting, outSegments, visible, onClosePress, onExportConfirm, keyframeCut, toggleKeyframeCut,
|
||||
toggleAutoMerge, renderOutFmt, preserveMovData, togglePreserveMovData, avoidNegativeTs, setAvoidNegativeTs,
|
||||
changeOutDir, outputDir, numStreamsTotal, numStreamsToCopy, setStreamsSelectorShown, currentSegIndex, invertCutSegments,
|
||||
exportConfirmEnabled, toggleExportConfirmEnabled,
|
||||
exportConfirmEnabled, toggleExportConfirmEnabled, segmentsToChapters, toggleSegmentsToChapters,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@ -103,7 +103,13 @@ const ExportConfirm = memo(({
|
||||
</ul>
|
||||
|
||||
<h3>{t('Advanced options')}</h3>
|
||||
|
||||
<ul>
|
||||
{autoMerge && <li>{t('Create chapters from segments?')} <Button height={20} onClick={toggleSegmentsToChapters}>{segmentsToChapters ? t('Yes') : t('No')}</Button></li>}
|
||||
</ul>
|
||||
|
||||
<p>{t('Depending on your specific file, you may have to try different options for best results.')}</p>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
{t('Cut mode:')} <KeyframeCutButton keyframeCut={keyframeCut} onClick={withBlur(() => toggleKeyframeCut(false))} />
|
||||
|
110
src/ffmpeg.js
110
src/ffmpeg.js
@ -7,7 +7,7 @@ import moment from 'moment';
|
||||
import i18n from 'i18next';
|
||||
import Timecode from 'smpte-timecode';
|
||||
|
||||
import { formatDuration, getOutPath, transferTimestamps, filenamify, isDurationValid } from './util';
|
||||
import { formatDuration, getOutPath, getOutDir, transferTimestamps, filenamify, isDurationValid } from './util';
|
||||
|
||||
const execa = window.require('execa');
|
||||
const { join, extname } = window.require('path');
|
||||
@ -482,64 +482,108 @@ export async function html5ifyDummy(filePath, outPath, onProgress) {
|
||||
await transferTimestamps(filePath, outPath);
|
||||
}
|
||||
|
||||
export async function mergeFiles({ paths, outPath, allStreams, outFormat, ffmpegExperimental, onProgress = () => {}, preserveMovData }) {
|
||||
async function writeChaptersFfmetadata(outDir, chapters) {
|
||||
if (!chapters) return undefined;
|
||||
|
||||
const path = join(outDir, `ffmetadata-${new Date().getTime()}.txt`);
|
||||
|
||||
const ffmetadata = chapters.map(({ start, end, name }, i) => {
|
||||
const nameOut = name || `Chapter ${i + 1}`;
|
||||
return `[CHAPTER]\nTIMEBASE=1/1000\nSTART=${Math.floor(start * 1000)}\nEND=${Math.floor(end * 1000)}\ntitle=${nameOut}`;
|
||||
}).join('\n\n');
|
||||
// console.log(ffmetadata);
|
||||
await fs.writeFile(path, ffmetadata);
|
||||
return path;
|
||||
}
|
||||
|
||||
export async function mergeFiles({ paths, outDir, outPath, allStreams, outFormat, ffmpegExperimental, onProgress = () => {}, preserveMovData, chapters }) {
|
||||
console.log('Merging files', { paths }, 'to', outPath);
|
||||
|
||||
const durations = await pMap(paths, getDuration, { concurrency: 1 });
|
||||
const totalDuration = sum(durations);
|
||||
|
||||
// Keep this similar to cut()
|
||||
const ffmpegArgs = [
|
||||
'-hide_banner',
|
||||
// No progress if we set loglevel warning :(
|
||||
// '-loglevel', 'warning',
|
||||
const ffmetadataPath = await writeChaptersFfmetadata(outDir, chapters);
|
||||
|
||||
// https://blog.yo1.dog/fix-for-ffmpeg-protocol-not-on-whitelist-error-for-urls/
|
||||
'-f', 'concat', '-safe', '0', '-protocol_whitelist', 'file,pipe', '-i', '-',
|
||||
try {
|
||||
// Keep this similar to cut()
|
||||
const ffmpegArgs = [
|
||||
'-hide_banner',
|
||||
// No progress if we set loglevel warning :(
|
||||
// '-loglevel', 'warning',
|
||||
|
||||
'-c', 'copy',
|
||||
// https://blog.yo1.dog/fix-for-ffmpeg-protocol-not-on-whitelist-error-for-urls/
|
||||
'-f', 'concat', '-safe', '0', '-protocol_whitelist', 'file,pipe', '-i', '-',
|
||||
|
||||
...(allStreams ? ['-map', '0'] : []),
|
||||
'-map_metadata', '0',
|
||||
// https://video.stackexchange.com/questions/23741/how-to-prevent-ffmpeg-from-dropping-metadata
|
||||
...getMovFlags(outFormat, preserveMovData),
|
||||
...(ffmetadataPath ? ['-f', 'ffmetadata', '-i', ffmetadataPath] : []),
|
||||
|
||||
// See https://github.com/mifi/lossless-cut/issues/170
|
||||
'-ignore_unknown',
|
||||
'-c', 'copy',
|
||||
|
||||
// https://superuser.com/questions/543589/information-about-ffmpeg-command-line-options
|
||||
...(ffmpegExperimental ? ['-strict', 'experimental'] : []),
|
||||
...(allStreams ? ['-map', '0'] : []),
|
||||
|
||||
...(outFormat ? ['-f', outFormat] : []),
|
||||
'-y', outPath,
|
||||
];
|
||||
'-map_metadata', '0',
|
||||
|
||||
console.log('ffmpeg', ffmpegArgs.join(' '));
|
||||
// https://video.stackexchange.com/questions/23741/how-to-prevent-ffmpeg-from-dropping-metadata
|
||||
...getMovFlags(outFormat, preserveMovData),
|
||||
|
||||
// https://superuser.com/questions/787064/filename-quoting-in-ffmpeg-concat
|
||||
const concatTxt = paths.map(file => `file '${join(file).replace(/'/g, "'\\''")}'`).join('\n');
|
||||
// See https://github.com/mifi/lossless-cut/issues/170
|
||||
'-ignore_unknown',
|
||||
|
||||
console.log(concatTxt);
|
||||
// https://superuser.com/questions/543589/information-about-ffmpeg-command-line-options
|
||||
...(ffmpegExperimental ? ['-strict', 'experimental'] : []),
|
||||
|
||||
const ffmpegPath = getFfmpegPath();
|
||||
const process = execa(ffmpegPath, ffmpegArgs);
|
||||
...(outFormat ? ['-f', outFormat] : []),
|
||||
'-y', outPath,
|
||||
];
|
||||
|
||||
handleProgress(process, totalDuration, onProgress);
|
||||
console.log('ffmpeg', ffmpegArgs.join(' '));
|
||||
|
||||
stringToStream(concatTxt).pipe(process.stdin);
|
||||
// https://superuser.com/questions/787064/filename-quoting-in-ffmpeg-concat
|
||||
const concatTxt = paths.map(file => `file '${join(file).replace(/'/g, "'\\''")}'`).join('\n');
|
||||
|
||||
const { stdout } = await process;
|
||||
console.log(stdout);
|
||||
console.log(concatTxt);
|
||||
|
||||
const ffmpegPath = getFfmpegPath();
|
||||
const process = execa(ffmpegPath, ffmpegArgs);
|
||||
|
||||
handleProgress(process, totalDuration, onProgress);
|
||||
|
||||
stringToStream(concatTxt).pipe(process.stdin);
|
||||
|
||||
const { stdout } = await process;
|
||||
console.log(stdout);
|
||||
} finally {
|
||||
if (ffmetadataPath) await fs.unlink(ffmetadataPath).catch((err) => console.error('Failed to delete', ffmetadataPath, err));
|
||||
}
|
||||
|
||||
await transferTimestamps(paths[0], outPath);
|
||||
}
|
||||
|
||||
export async function autoMergeSegments({ customOutDir, sourceFile, isCustomFormatSelected, outFormat, segmentPaths, ffmpegExperimental, onProgress, preserveMovData, autoDeleteMergedSegments }) {
|
||||
async function createChaptersFromSegments({ segmentPaths, chapterNames }) {
|
||||
if (chapterNames) {
|
||||
try {
|
||||
const durations = await pMap(segmentPaths, (segmentPath) => getDuration(segmentPath), { concurrency: 3 });
|
||||
let timeAt = 0;
|
||||
return durations.map((duration, i) => {
|
||||
const ret = { start: timeAt, end: timeAt + duration, name: chapterNames[i] };
|
||||
timeAt += duration;
|
||||
return ret;
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to create chapters from segments', err);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function autoMergeSegments({ customOutDir, sourceFile, isCustomFormatSelected, outFormat, segmentPaths, ffmpegExperimental, onProgress, preserveMovData, autoDeleteMergedSegments, chapterNames }) {
|
||||
const ext = getOutFileExtension({ isCustomFormatSelected, outFormat, filePath: sourceFile });
|
||||
const fileName = `cut-merged-${new Date().getTime()}${ext}`;
|
||||
const outPath = getOutPath(customOutDir, sourceFile, fileName);
|
||||
const outDir = getOutDir(customOutDir, sourceFile);
|
||||
|
||||
await mergeFiles({ paths: segmentPaths, outPath, outFormat, allStreams: true, ffmpegExperimental, onProgress, preserveMovData });
|
||||
const chapters = await createChaptersFromSegments({ segmentPaths, chapterNames });
|
||||
|
||||
await mergeFiles({ paths: segmentPaths, outDir, outPath, outFormat, allStreams: true, ffmpegExperimental, onProgress, preserveMovData, chapters });
|
||||
if (autoDeleteMergedSegments) await pMap(segmentPaths, path => fs.unlink(path), { concurrency: 5 });
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user