Implement youtube export dialog

Also improve youtube import
Closes #657
pull/699/head
Mikael Finstad 5 years ago
parent 9a98797758
commit 7ff74110e8
No known key found for this signature in database
GPG Key ID: 25AB36E3E81CBC26

@ -61,6 +61,7 @@ The main feature is lossless trimming and cutting of video and audio files, whic
- Quickly change a H264/H265 MKV video to MOV or MP4 for playback on iPhone
- Import a list of cut times from other tool as a EDL (edit decision list, CSV) and run these cuts with LosslessCut
- Export a list of cut times as a CSV EDL and process these in another tool
- Export cut times as YouTube Chapters
- Quickly cut a file by its MP4/MKV chapters
- Quickly cut a [YouTube video](https://youtube-dl.org/) by its chapters (or music times from a comment)
- Change the language of a file's audio/subtitle tracks

@ -85,6 +85,12 @@ module.exports = (app, mainWindow, newVersion) => {
mainWindow.webContents.send('exportEdlFile', 'tsv-human');
},
},
{
label: 'Start times as YouTube Chapters',
click() {
mainWindow.webContents.send('exportEdlYouTube');
},
},
],
},
{ type: 'separator' },

@ -48,13 +48,15 @@ import {
getDuration, getTimecodeFromStreams, createChaptersFromSegments,
} from './ffmpeg';
import { saveCsv, saveTsv, loadCsv, loadXmeml, loadCue, loadPbf, saveCsvHuman } from './edlStore';
import { formatYouTube } from './edlFormats';
import {
getOutPath, formatDuration, toast, errorToast, showFfmpegFail, setFileNameTitle, getOutDir, withBlur,
getOutPath, toast, errorToast, showFfmpegFail, setFileNameTitle, getOutDir, withBlur,
checkDirWriteAccess, dirExists, openDirToast, isMasBuild, isStoreBuild, dragPreventer, doesPlayerSupportFile,
isDurationValid, isWindows, filenamify, getOutFileExtension, generateSegFileName, defaultOutSegTemplate,
hasDuplicates, havePermissionToReadFile,
} from './util';
import { askForOutDir, askForImportChapters, createNumSegments, createFixedDurationSegments, promptTimeOffset, askForHtml5ifySpeed, askForYouTubeInput, askForFileOpenAction, confirmExtractAllStreamsDialog, cleanupFilesDialog, showDiskFull, showCutFailedDialog, labelSegmentDialog } from './dialogs';
import { formatDuration } from './util/duration';
import { askForOutDir, askForImportChapters, createNumSegments, createFixedDurationSegments, promptTimeOffset, askForHtml5ifySpeed, askForYouTubeInput, askForFileOpenAction, confirmExtractAllStreamsDialog, cleanupFilesDialog, showDiskFull, showCutFailedDialog, labelSegmentDialog, openYouTubeChaptersDialog } from './dialogs';
import { openSendReportDialog } from './reporting';
import { fallbackLng } from './i18n';
import { createSegment, createInitialCutSegments, getCleanCutSegments, getSegApparentStart, findSegmentsAtCursor } from './segments';
@ -1688,6 +1690,12 @@ const App = memo(() => {
}
}
async function exportEdlYouTube() {
if (!checkFileOpened()) return;
await openYouTubeChaptersDialog(formatYouTube(apparentCutSegments));
}
async function importEdlFile(e, type) {
if (!checkFileOpened()) return;
@ -1802,6 +1810,7 @@ const App = memo(() => {
electron.ipcRenderer.on('showStreamsSelector', showStreamsSelector);
electron.ipcRenderer.on('importEdlFile', importEdlFile);
electron.ipcRenderer.on('exportEdlFile', exportEdlFile);
electron.ipcRenderer.on('exportEdlYouTube', exportEdlYouTube);
electron.ipcRenderer.on('openHelp', toggleHelp);
electron.ipcRenderer.on('openSettings', toggleSettings);
electron.ipcRenderer.on('openAbout', openAbout);
@ -1823,6 +1832,7 @@ const App = memo(() => {
electron.ipcRenderer.removeListener('showStreamsSelector', showStreamsSelector);
electron.ipcRenderer.removeListener('importEdlFile', importEdlFile);
electron.ipcRenderer.removeListener('exportEdlFile', exportEdlFile);
electron.ipcRenderer.removeListener('exportEdlYouTube', exportEdlYouTube);
electron.ipcRenderer.removeListener('openHelp', toggleHelp);
electron.ipcRenderer.removeListener('openSettings', toggleSettings);
electron.ipcRenderer.removeListener('openAbout', openAbout);
@ -1837,7 +1847,7 @@ const App = memo(() => {
}, [
mergeFiles, outputDir, filePath, customOutDir, startTimeOffset, html5ifyCurrentFile,
createDummyVideo, extractAllStreams, userOpenFiles, openSendReportDialogWithState,
loadEdlFile, cutSegments, edlFilePath, toggleHelp, toggleSettings, assureOutDirAccess, html5ifyAndLoad, html5ifyInternal,
loadEdlFile, cutSegments, apparentCutSegments, edlFilePath, toggleHelp, toggleSettings, assureOutDirAccess, html5ifyAndLoad, html5ifyInternal,
loadCutSegments, duration, checkFileOpened, load, fileFormat, reorderSegsByStartTime, closeFile, clearSegments, fixInvalidDuration,
]);

@ -13,7 +13,7 @@ import { tomorrow as style } from 'react-syntax-highlighter/dist/esm/styles/hljs
import JSON5 from 'json5';
import { askForMetadataKey } from './dialogs';
import { formatDuration } from './util';
import { formatDuration } from './util/duration';
import { getStreamFps } from './ffmpeg';
const ReactSwal = withReactContent(Swal);

@ -5,7 +5,8 @@ import { IoMdKey } from 'react-icons/io';
import { useTranslation } from 'react-i18next';
// import useTraceUpdate from 'use-trace-update';
import { getSegColors, parseDuration, formatDuration } from './util';
import { getSegColors } from './util';
import { formatDuration, parseDuration } from './util/duration';
import { primaryTextColor } from './colors';
import SetCutpointButton from './components/SetCutpointButton';

@ -4,7 +4,7 @@ import { FaTrashAlt } from 'react-icons/fa';
import { mySpring } from './animations';
import { formatDuration } from './util';
import { formatDuration } from './util/duration';
const TimelineSeg = memo(({

@ -1,6 +1,7 @@
import strongDataUri from 'strong-data-uri';
import { formatDuration, getOutPath, transferTimestamps } from './util';
import { getOutPath, transferTimestamps } from './util';
import { formatDuration } from './util/duration';
import { captureFrame as ffmpegCaptureFrame } from './ffmpeg';

@ -3,8 +3,9 @@ import Swal from 'sweetalert2';
import i18n from 'i18next';
import withReactContent from 'sweetalert2-react-content';
import { parseDuration } from './util';
import { parseDuration } from './util/duration';
import { parseYouTube } from './edlFormats';
import CopyClipboardButton from './components/CopyClipboardButton';
const electron = window.require('electron'); // eslint-disable-line
@ -264,6 +265,23 @@ export async function showCutFailedDialog({ detectedFileFormat }) {
return value;
}
export function openYouTubeChaptersDialog(text) {
ReactSwal.fire({
showCloseButton: true,
title: i18n.t('YouTube Chapters'),
html: (
<div style={{ textAlign: 'left', overflow: 'auto', maxHeight: 300, overflowY: 'auto' }}>
<p>{i18n.t('Copy to YouTube description/comment:')} <CopyClipboardButton text={text} /></p>
<div style={{ fontWeight: 600, fontSize: 12, whiteSpace: 'pre-wrap' }} contentEditable suppressContentEditableWarning>
{text}
</div>
</div>
),
});
}
export async function labelSegmentDialog(currentName) {
const { value } = await Swal.fire({
showCancelButton: true,

@ -5,6 +5,8 @@ import csvParse from 'csv-parse';
import pify from 'pify';
import sortBy from 'lodash/sortBy';
import { formatDuration } from './util/duration';
const csvParseAsync = pify(csvParse);
export async function parseCsv(str) {
@ -78,7 +80,7 @@ export function parseXmeml(xmlStr) {
}
export function parseYouTube(str) {
const regex = /(?:([0-9]{2,}):)?([0-9]{2}):([0-9]{2})(?:\.([0-9]{3}))?[^\S\n]+([^\n]*)\n/g;
const regex = /(?:([0-9]{2,}):)?([0-9]{1,2}):([0-9]{1,2})(?:\.([0-9]{3}))?[^\S\n]+([^\n]*)\n/g;
const lines = [];
@ -110,3 +112,11 @@ export function parseYouTube(str) {
return edl.filter((ed) => ed.start !== ed.end);
}
export function formatYouTube(segments) {
return segments.map((segment) => {
const timeStr = formatDuration({ seconds: segment.start, showMs: false, shorten: true });
const namePart = segment.name ? ` ${segment.name}` : '';
return `${timeStr}${namePart}`;
}).join('\n');
}

@ -1,11 +1,10 @@
/* eslint-disable no-undef */
import { parseYouTube } from './edlFormats';
import { parseYouTube, formatYouTube } from './edlFormats';
it('parseYoutube', () => {
const str = `
Jump to chapters:
00:00 Test 1
00:01 Test 2
0:00 00:01 Test 1
00:01 "Test 2":
00:02 00:57 double
00:01:01 Test 3
01:01:01.012 Test 4
@ -22,8 +21,8 @@ Jump to chapters:
`;
const edl = parseYouTube(str);
expect(edl).toEqual([
{ start: 0, end: 1, name: 'Test 1' },
{ start: 1, end: 2, name: 'Test 2' },
{ start: 0, end: 1, name: '00:01 Test 1' },
{ start: 1, end: 2, name: '"Test 2":' },
{ start: 2, end: 4, name: '00:57 double' },
{ start: 4, end: 5, name: '' },
{ start: 5, end: 61, name: '' },
@ -42,3 +41,20 @@ it('parseYouTube eol', () => {
{ start: 1, end: undefined, name: 'Test 2' },
]);
});
it('formatYouTube', () => {
expect(formatYouTube([
{ start: 1, end: 2, name: 'Label 🎉' },
{ start: 3, end: 5 },
{ start: 10000, end: 10001, name: '' },
]).split('\n')).toEqual([
'0:01 Label 🎉',
'0:03',
'2:46:40',
]);
expect(formatYouTube([
{ start: 0, end: 100 },
]).split('\n')).toEqual([
'0:00',
]);
});

@ -2,7 +2,7 @@ import csvStringify from 'csv-stringify';
import pify from 'pify';
import { parseCuesheet, parseXmeml, parseCsv, parsePbf } from './edlFormats';
import { formatDuration } from './util';
import { formatDuration } from './util/duration';
const fs = window.require('fs-extra');
const cueParser = window.require('cue-parser');

@ -1,4 +1,3 @@
import padStart from 'lodash/padStart';
import Swal from 'sweetalert2';
import i18n from 'i18next';
import lodashTemplate from 'lodash/template';
@ -10,42 +9,6 @@ const fs = window.require('fs-extra');
const open = window.require('open');
const os = window.require('os');
export function formatDuration({ seconds: secondsIn, fileNameFriendly, fps }) {
const seconds = secondsIn || 0;
const secondsAbs = Math.abs(seconds);
const minutes = secondsAbs / 60;
const hours = minutes / 60;
const hoursPadded = padStart(Math.floor(hours), 2, '0');
const minutesPadded = padStart(Math.floor(minutes % 60), 2, '0');
const secondsPadded = padStart(Math.floor(secondsAbs) % 60, 2, '0');
const ms = secondsAbs - Math.floor(secondsAbs);
const msPadded = fps != null
? padStart(Math.floor(ms * fps), 2, '0')
: padStart(Math.floor(ms * 1000), 3, '0');
// Be nice to filenames and use .
const delim = fileNameFriendly ? '.' : ':';
const sign = secondsIn < 0 ? '-' : '';
return `${sign}${hoursPadded}${delim}${minutesPadded}${delim}${secondsPadded}.${msPadded}`;
}
export function parseDuration(str) {
if (!str) return undefined;
const match = str.trim().match(/^(-?)(\d{2}):(\d{2}):(\d{2})\.(\d{3})$/);
if (!match) return undefined;
const isNegatve = match[1] === '-';
const hours = parseInt(match[2], 10);
const minutes = parseInt(match[3], 10);
const seconds = parseInt(match[4], 10);
const ms = parseInt(match[5], 10);
if (hours > 59 || minutes > 59 || seconds > 59) return undefined;
let ret = (((((hours * 60) + minutes) * 60) + seconds) + (ms / 1000));
if (isNegatve) ret *= -1;
return ret;
}
export function getOutDir(customOutDir, filePath) {
if (customOutDir) return customOutDir;
if (filePath) return path.dirname(filePath);

@ -0,0 +1,48 @@
import padStart from 'lodash/padStart';
export function formatDuration({ seconds: secondsIn, fileNameFriendly, showMs = true, shorten = false, fps }) {
const seconds = secondsIn || 0;
const secondsAbs = Math.abs(seconds);
const minutes = Math.floor((secondsAbs / 60) % 60);
const hours = Math.floor(secondsAbs / 60 / 60);
// Be nice to filenames and use .
const delim = fileNameFriendly ? '.' : ':';
let hoursPart = '';
if (!shorten || hours !== 0) {
const hoursPadded = shorten ? `${hours}` : padStart(hours, 2, '0');
hoursPart = `${hoursPadded}${delim}`;
}
const minutesPadded = shorten && hours === 0 ? `${minutes}` : padStart(minutes, 2, '0');
const secondsPadded = padStart(Math.floor(secondsAbs) % 60, 2, '0');
const ms = secondsAbs - Math.floor(secondsAbs);
let msPart = '';
if (showMs) {
const msPadded = fps != null
? padStart(Math.floor(ms * fps), 2, '0')
: padStart(Math.floor(ms * 1000), 3, '0');
msPart = `.${msPadded}`;
}
const sign = secondsIn < 0 ? '-' : '';
return `${sign}${hoursPart}${minutesPadded}${delim}${secondsPadded}${msPart}`;
}
export function parseDuration(str) {
if (!str) return undefined;
const match = str.trim().match(/^(-?)(\d{2}):(\d{2}):(\d{2})\.(\d{3})$/);
if (!match) return undefined;
const isNegatve = match[1] === '-';
const hours = parseInt(match[2], 10);
const minutes = parseInt(match[3], 10);
const seconds = parseInt(match[4], 10);
const ms = parseInt(match[5], 10);
if (hours > 59 || minutes > 59 || seconds > 59) return undefined;
let ret = (((((hours * 60) + minutes) * 60) + seconds) + (ms / 1000));
if (isNegatve) ret *= -1;
return ret;
}

@ -0,0 +1,14 @@
import { formatDuration } from './duration';
it('should format duration properly', () => {
expect(formatDuration({ seconds: 1.5, fps: 30 })).toBe('00:00:01.15');
expect(formatDuration({ seconds: 1.5, fps: 30, shorten: true })).toBe('0:01.15');
expect(formatDuration({ seconds: 1.5, fps: 30, fileNameFriendly: true })).toBe('00.00.01.15');
expect(formatDuration({ seconds: -1.5, fps: 30 })).toBe('-00:00:01.15');
expect(formatDuration({ seconds: 101.5 })).toBe('00:01:41.500');
expect(formatDuration({ seconds: 101.5, shorten: true })).toBe('1:41.500');
expect(formatDuration({ seconds: 10000 })).toBe('02:46:40.000');
expect(formatDuration({ seconds: 10000, shorten: true })).toBe('2:46:40.000');
expect(formatDuration({ seconds: 101.5, showMs: false })).toBe('00:01:41');
expect(formatDuration({ seconds: 101.5, showMs: false, shorten: true })).toBe('1:41');
});
Loading…
Cancel
Save