1
0
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:
Mikael Finstad 2020-12-08 23:59:41 +01:00
parent 44cd0cd810
commit 5f6e9f33dc
4 changed files with 94 additions and 36 deletions

View File

@ -23,6 +23,7 @@ const defaults = {
avoidNegativeTs: 'make_zero',
hideNotifications: undefined,
autoLoadTimecode: false,
segmentsToChapters: false,
},
};

View File

@ -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}

View File

@ -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))} />

View File

@ -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 });
}