- Allow editing segment tags #427
- Store main project file as .lcc (JSON5 format), for future flexibility. existing CSV will still be loaded and converted to .llc #545
- Rename menu items to Export/import project (as we are not changing the save location) #593
pull/840/head
Mikael Finstad 2021-08-24 16:21:26 +07:00
rodzic 4a1ec76a90
commit adcf79a6c2
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 25AB36E3E81CBC26
7 zmienionych plików z 181 dodań i 74 usunięć

Wyświetl plik

@ -44,6 +44,7 @@ The main feature is lossless trimming and cutting of video and audio files, whic
- Video thumbnails and audio waveform
- Edit file metadata and per-stream metadata
- Cut with chapter marks
- Annotate segments with tags
## Example lossless use cases

Wyświetl plik

@ -31,20 +31,26 @@ module.exports = (app, mainWindow, newVersion) => {
},
{ type: 'separator' },
{
label: i18n.t('Load project (CSV)'),
label: i18n.t('Import project (LLC)...'),
click() {
mainWindow.webContents.send('importEdlFile', 'csv');
mainWindow.webContents.send('importEdlFile', 'llc');
},
},
{
label: i18n.t('Save project (CSV)'),
label: i18n.t('Export project (LLC)...'),
click() {
mainWindow.webContents.send('exportEdlFile', 'csv');
mainWindow.webContents.send('exportEdlFile', 'llc');
},
},
{
label: i18n.t('Import project'),
submenu: [
{
label: i18n.t('LosslessCut (CSV)'),
click() {
mainWindow.webContents.send('importEdlFile', 'csv');
},
},
{
label: i18n.t('EDL (MPlayer)'),
click() {
@ -80,6 +86,12 @@ module.exports = (app, mainWindow, newVersion) => {
{
label: i18n.t('Export project'),
submenu: [
{
label: i18n.t('LosslessCut (CSV)'),
click() {
mainWindow.webContents.send('exportEdlFile', 'csv');
},
},
{
label: i18n.t('Timestamps (CSV)'),
click() {

Wyświetl plik

@ -11,6 +11,7 @@ import filePathToUrl from 'file-url';
import i18n from 'i18next';
import { useTranslation } from 'react-i18next';
import Mousetrap from 'mousetrap';
import JSON5 from 'json5';
import fromPairs from 'lodash/fromPairs';
import clamp from 'lodash/clamp';
@ -48,16 +49,16 @@ import {
isStreamThumbnail, isAudioSupported, isIphoneHevc, tryReadChaptersToEdl,
getDuration, getTimecodeFromStreams, createChaptersFromSegments,
} from './ffmpeg';
import { saveCsv, saveTsv, loadCsv, loadXmeml, loadCue, loadPbf, loadMplayerEdl, saveCsvHuman } from './edlStore';
import { saveCsv, saveTsv, loadCsv, loadXmeml, loadCue, loadPbf, loadMplayerEdl, saveCsvHuman, saveLlcProject, loadLlcProject } from './edlStore';
import { formatYouTube } from './edlFormats';
import {
getOutPath, toast, errorToast, showFfmpegFail, setFileNameTitle, getOutDir, withBlur,
checkDirWriteAccess, dirExists, openDirToast, isMasBuild, isStoreBuild, dragPreventer, doesPlayerSupportFile,
isDurationValid, isWindows, filenamify, getOutFileExtension, generateSegFileName, defaultOutSegTemplate,
hasDuplicates, havePermissionToReadFile, isMac, getFileBaseName,
hasDuplicates, havePermissionToReadFile, isMac, getFileBaseName, resolvePathIfNeeded, pathExists,
} from './util';
import { formatDuration } from './util/duration';
import { askForOutDir, askForImportChapters, createNumSegments, createFixedDurationSegments, promptTimeOffset, askForHtml5ifySpeed, askForYouTubeInput, askForFileOpenAction, confirmExtractAllStreamsDialog, cleanupFilesDialog, showDiskFull, showCutFailedDialog, labelSegmentDialog, openYouTubeChaptersDialog, showMergeDialog, showOpenAndMergeDialog, openAbout, showJson5Dialog } from './dialogs';
import { askForOutDir, askForImportChapters, createNumSegments, createFixedDurationSegments, promptTimeOffset, askForHtml5ifySpeed, askForYouTubeInput, askForFileOpenAction, confirmExtractAllStreamsDialog, cleanupFilesDialog, showDiskFull, showCutFailedDialog, labelSegmentDialog, openYouTubeChaptersDialog, showMergeDialog, showOpenAndMergeDialog, openAbout, showEditableJsonDialog } from './dialogs';
import { openSendReportDialog } from './reporting';
import { fallbackLng } from './i18n';
import { createSegment, createInitialCutSegments, getCleanCutSegments, getSegApparentStart, findSegmentsAtCursor, sortSegments, invertSegments, getSegmentTags } from './segments';
@ -70,7 +71,7 @@ const isDev = window.require('electron-is-dev');
const electron = window.require('electron'); // eslint-disable-line
const trash = window.require('trash');
const { unlink, exists, readdir } = window.require('fs-extra');
const { extname, parse: parsePath, sep: pathSep, join: pathJoin, normalize: pathNormalize, resolve: pathResolve, isAbsolute: pathIsAbsolute, basename } = window.require('path');
const { extname, parse: parsePath, sep: pathSep, join: pathJoin, normalize: pathNormalize, basename, dirname } = window.require('path');
const { dialog } = electron.remote;
@ -346,6 +347,7 @@ const App = memo(() => {
}, [duration, haveInvalidSegs, sortedCutSegments]);
const updateSegAtIndex = useCallback((index, newProps) => {
if (index < 0) return;
const cutSegmentsNew = [...cutSegments];
cutSegmentsNew.splice(index, 1, { ...cutSegments[index], ...newProps });
setCutSegments(cutSegmentsNew);
@ -372,9 +374,21 @@ const App = memo(() => {
if (value != null) updateSegAtIndex(index, { name: value });
}, [cutSegments, updateSegAtIndex, maxLabelLength]);
const onViewSegmentTagsPress = useCallback((segment) => {
showJson5Dialog({ title: 'Segment tags', json: getSegmentTags(segment) });
}, []);
const onViewSegmentTagsPress = useCallback(async (index) => {
const segment = cutSegments[index];
function inputValidator(jsonStr) {
try {
const json = JSON5.parse(jsonStr);
if (!(typeof json === 'object' && Object.values(json).every((val) => typeof val === 'string'))) throw new Error();
return undefined;
} catch (err) {
return i18n.t('Invalid JSON');
}
}
const tags = getSegmentTags(segment);
const newTagsStr = await showEditableJsonDialog({ title: i18n.t('Segment tags'), text: i18n.t('View and edit segment tags in JSON5 format:'), inputValue: Object.keys(tags).length > 0 ? JSON5.stringify(tags, null, 2) : '', inputValidator });
if (newTagsStr != null) updateSegAtIndex(index, { tags: JSON5.parse(newTagsStr) });
}, [cutSegments, updateSegAtIndex]);
const updateSegOrder = useCallback((index, newOrder) => {
if (newOrder > cutSegments.length - 1 || newOrder < 0) return;
@ -488,13 +502,17 @@ const App = memo(() => {
const effectiveFilePath = previewFilePath || filePath;
const fileUri = effectiveFilePath ? filePathToUrl(effectiveFilePath) : '';
const getEdlFilePath = useCallback((fp) => getOutPath(customOutDir, fp, 'llc-edl.csv'), [customOutDir]);
const edlFilePath = getEdlFilePath(filePath);
const projectSuffix = 'proj.llc';
const oldProjectSuffix = 'llc-edl.csv';
const getEdlFilePath = useCallback((fp) => getOutPath(customOutDir, fp, projectSuffix), [customOutDir]);
// Old versions of LosslessCut used CSV files:
const getEdlFilePathOld = useCallback((fp) => getOutPath(customOutDir, fp, oldProjectSuffix), [customOutDir]);
const edlFilePath = useMemo(() => getEdlFilePath(filePath), [getEdlFilePath, filePath]);
const currentSaveOperation = useMemo(() => {
if (!edlFilePath) return undefined;
return { cutSegments, edlFilePath };
}, [cutSegments, edlFilePath]);
return { cutSegments, edlFilePath, filePath };
}, [cutSegments, edlFilePath, filePath]);
const [debouncedSaveOperation] = useDebounce(currentSaveOperation, isDev ? 2000 : 500);
@ -504,7 +522,7 @@ const App = memo(() => {
// NOTE: Could lose a save if user closes too fast, but not a big issue I think
if (!autoSaveProjectFile || !debouncedSaveOperation) return;
const { cutSegments: saveOperationCutSegments, edlFilePath: saveOperationEdlFilePath } = debouncedSaveOperation;
const { cutSegments: saveOperationCutSegments, edlFilePath: saveOperationEdlFilePath, filePath: saveOperationFilePath } = debouncedSaveOperation;
try {
// Initial state? Don't save
@ -515,7 +533,7 @@ const App = memo(() => {
return;
}
await saveCsv(saveOperationEdlFilePath, saveOperationCutSegments);
await saveLlcProject({ savePath: saveOperationEdlFilePath, filePath: saveOperationFilePath, cutSegments: saveOperationCutSegments });
lastSaveOperation.current = debouncedSaveOperation;
} catch (err) {
errorToast(i18n.t('Unable to save project file'));
@ -1160,7 +1178,7 @@ const App = memo(() => {
setCutSegments(validEdl.map(createSegment));
}, [setCutSegments]);
const loadEdlFile = useCallback(async (path, type = 'csv') => {
const loadEdlFile = useCallback(async (path, type) => {
try {
let edl;
if (type === 'csv') edl = await loadCsv(path);
@ -1168,6 +1186,10 @@ const App = memo(() => {
else if (type === 'cue') edl = await loadCue(path);
else if (type === 'pbf') edl = await loadPbf(path);
else if (type === 'mplayer') edl = await loadMplayerEdl(path);
else if (type === 'llc') {
const project = await loadLlcProject(path);
edl = project.cutSegments;
}
loadCutSegments(edl);
} catch (err) {
@ -1176,8 +1198,8 @@ const App = memo(() => {
}
}, [loadCutSegments]);
const load = useCallback(async ({ filePath: fp, customOutDir: cod, html5FriendlyPathRequested, dummyVideoPathRequested }) => {
console.log('Load', { fp, cod, html5FriendlyPathRequested, dummyVideoPathRequested });
const load = useCallback(async ({ filePath: fp, customOutDir: cod, html5FriendlyPathRequested, dummyVideoPathRequested, projectPath }) => {
console.log('Load', { fp, cod, html5FriendlyPathRequested, dummyVideoPathRequested, projectPath });
if (working) return;
@ -1283,9 +1305,14 @@ const App = memo(() => {
}
const openedFileEdlPath = getEdlFilePath(fp);
const openedFileEdlPathOld = getEdlFilePathOld(fp);
if (await exists(openedFileEdlPath)) {
await loadEdlFile(openedFileEdlPath);
if (projectPath) {
await loadEdlFile(projectPath, 'llc');
} else if (await exists(openedFileEdlPath)) {
await loadEdlFile(openedFileEdlPath, 'llc');
} else if (await exists(openedFileEdlPathOld)) {
await loadEdlFile(openedFileEdlPathOld, 'csv');
} else {
const edl = await tryReadChaptersToEdl(fp);
if (edl.length > 0 && enableAskForImportChapters && (await askForImportChapters())) {
@ -1311,7 +1338,7 @@ const App = memo(() => {
} finally {
setWorking();
}
}, [resetState, working, createDummyVideo, loadEdlFile, getEdlFilePath, loadCutSegments, enableAskForImportChapters, showUnsupportedFileMessage, autoLoadTimecode, outFormatLocked, showPreviewFileLoadedMessage]);
}, [resetState, working, createDummyVideo, loadEdlFile, getEdlFilePath, getEdlFilePathOld, loadCutSegments, enableAskForImportChapters, showUnsupportedFileMessage, autoLoadTimecode, outFormatLocked, showPreviewFileLoadedMessage]);
const toggleHelp = useCallback(() => setHelpVisible(val => !val), []);
const toggleSettings = useCallback(() => setSettingsVisible(val => !val), []);
@ -1496,12 +1523,58 @@ const App = memo(() => {
setCopyStreamIdsForPath(path, () => fromPairs(streams.map(({ index }) => [index, true])));
}, [externalStreamFiles]);
const userOpenFiles = useCallback(async (filePathsRaw) => {
console.log('userOpenFiles');
console.log(filePathsRaw.join('\n'));
const userOpenSingleFile = useCallback(async ({ path: pathIn, projectPath }) => {
let path = pathIn;
if (projectPath) {
console.log('Loading LLC project', projectPath);
const project = await loadLlcProject(projectPath);
const { mediaFileName } = project;
console.log({ mediaFileName });
if (!mediaFileName) return;
path = pathJoin(dirname(projectPath), mediaFileName);
}
// Need to resolve relative paths https://github.com/mifi/lossless-cut/issues/639
const filePaths = filePathsRaw.map((path) => (pathIsAbsolute(path) ? path : pathResolve(path)));
// Because Apple is being nazi about the ability to open "copy protected DVD files"
const disallowVob = isMasBuild;
if (disallowVob && /\.vob$/i.test(path)) {
toast.fire({ icon: 'error', text: 'Unfortunately .vob files are not supported in the App Store version of LosslessCut due to Apple restrictions' });
return;
}
if (!(await pathExists(path))) {
errorToast(i18n.t('The media you tried to open does not exist'));
return;
}
if (!(await havePermissionToReadFile(path))) {
errorToast(i18n.t('You do not have permission to access this file'));
return;
}
const { newCustomOutDir, cancel } = await assureOutDirAccess(path);
if (cancel) return;
const doLoad = () => load({ filePath: path, customOutDir: newCustomOutDir, projectPath });
// If no file is already opened, just load the new file
if (!isFileOpened) {
doLoad();
return;
}
const openFileResponse = enableAskForFileOpenAction ? await askForFileOpenAction() : 'open';
if (openFileResponse === 'open') {
doLoad();
} else if (openFileResponse === 'add') {
addStreamSourceFile(path);
setStreamsSelectorShown(true);
}
}, [addStreamSourceFile, assureOutDirAccess, enableAskForFileOpenAction, isFileOpened, load]);
const userOpenFiles = useCallback(async (filePaths) => {
console.log('userOpenFiles');
console.log(filePaths.join('\n'));
if (filePaths.length < 1) return;
if (filePaths.length > 1) {
@ -1509,37 +1582,8 @@ const App = memo(() => {
return;
}
const firstFile = filePaths[0];
// Because Apple is being nazi about the ability to open "copy protected DVD files"
const disallowVob = isMasBuild;
if (disallowVob && /\.vob$/i.test(firstFile)) {
toast.fire({ icon: 'error', text: 'Unfortunately .vob files are not supported in the App Store version of LosslessCut due to Apple restrictions' });
return;
}
if (!(await havePermissionToReadFile(firstFile))) {
errorToast(i18n.t('You do not have permission to access this file'));
return;
}
const { newCustomOutDir, cancel } = await assureOutDirAccess(firstFile);
if (cancel) return;
if (!isFileOpened) {
load({ filePath: firstFile, customOutDir: newCustomOutDir });
return;
}
const openFileResponse = enableAskForFileOpenAction ? await askForFileOpenAction() : 'open';
if (openFileResponse === 'open') {
load({ filePath: firstFile, customOutDir: newCustomOutDir });
} else if (openFileResponse === 'add') {
addStreamSourceFile(firstFile);
setStreamsSelectorShown(true);
}
}, [addStreamSourceFile, isFileOpened, load, mergeFiles, assureOutDirAccess, enableAskForFileOpenAction]);
await userOpenSingleFile({ path: filePaths[0] });
}, [mergeFiles, userOpenSingleFile]);
const checkFileOpened = useCallback(() => {
if (isFileOpened) return true;
@ -1554,13 +1598,22 @@ const App = memo(() => {
focusWindow();
if (filePaths.length === 1 && filePaths[0].toLowerCase().endsWith('.csv')) {
if (!checkFileOpened()) return;
loadEdlFile(filePaths[0]);
return;
if (filePaths.length === 1) {
const firstFilePath = filePaths[0];
const filePathLowerCase = firstFilePath.toLowerCase();
if (filePathLowerCase.endsWith('.llc')) {
if (isFileOpened) loadEdlFile(firstFilePath, 'llc');
else userOpenSingleFile({ projectPath: firstFilePath }); // Open .llc AND media contained within
return;
}
if (filePathLowerCase.endsWith('.csv')) {
if (!checkFileOpened()) return;
loadEdlFile(firstFilePath, 'csv');
return;
}
}
userOpenFiles(filePaths);
}, [userOpenFiles, loadEdlFile, checkFileOpened]);
}, [userOpenFiles, loadEdlFile, checkFileOpened, isFileOpened, userOpenSingleFile]);
const html5ify = useCallback(async ({ customOutDir: cod, filePath: fp, speed, hasAudio: ha, hasVideo: hv }) => {
const path = getHtml5ifiedPath(cod, fp, speed);
@ -1679,14 +1732,18 @@ const App = memo(() => {
} else if (type === 'csv-human') {
ext = 'csv';
filters = [{ name: i18n.t('TXT files'), extensions: [ext, 'txt'] }];
} else if (type === 'llc') {
ext = 'llc';
filters = [{ name: i18n.t('LosslessCut project'), extensions: [ext, 'llc'] }];
}
const { canceled, filePath: fp } = await dialog.showSaveDialog({ defaultPath: `${new Date().getTime()}.${ext}`, filters });
if (canceled || !fp) return;
console.log('Saving', type, fp);
if (type === 'csv') await saveCsv(fp, cutSegments);
else if (type === 'tsv-human') await saveTsv(fp, cutSegments);
else if (type === 'csv-human') await saveCsvHuman(fp, cutSegments);
const { canceled, filePath: savePath } = await dialog.showSaveDialog({ defaultPath: `${new Date().getTime()}.${ext}`, filters });
if (canceled || !savePath) return;
console.log('Saving', type, savePath);
if (type === 'csv') await saveCsv(savePath, cutSegments);
else if (type === 'tsv-human') await saveTsv(savePath, cutSegments);
else if (type === 'csv-human') await saveCsvHuman(savePath, cutSegments);
else if (type === 'llc') await saveLlcProject({ savePath, filePath, cutSegments });
} catch (err) {
errorToast(i18n.t('Failed to export project'));
console.error('Failed to export project', type, err);
@ -1714,6 +1771,7 @@ const App = memo(() => {
else if (type === 'cue') filters = [{ name: i18n.t('CUE files'), extensions: ['cue'] }];
else if (type === 'pbf') filters = [{ name: i18n.t('PBF files'), extensions: ['pbf'] }];
else if (type === 'mplayer') filters = [{ name: i18n.t('MPlayer EDL'), extensions: ['*'] }];
else if (type === 'llc') filters = [{ name: i18n.t('LosslessCut project'), extensions: ['llc'] }];
const { canceled, filePaths } = await dialog.showOpenDialog({ properties: ['openFile'], filters });
if (canceled || filePaths.length < 1) return;
@ -1792,7 +1850,7 @@ const App = memo(() => {
}
}
const fileOpened = (event, filePaths) => { userOpenFiles(filePaths); };
const fileOpened = (event, filePaths) => { userOpenFiles(filePaths.map(resolvePathIfNeeded)); };
const showStreamsSelector = () => setStreamsSelectorShown(true);
const openSendReportDialog2 = () => { openSendReportDialogWithState(); };
const closeFile2 = () => { closeFile(); };

Wyświetl plik

@ -10,7 +10,6 @@ import isEqual from 'lodash/isEqual';
import useDebounce from 'react-use/lib/useDebounce';
import scrollIntoView from 'scroll-into-view-if-needed';
import { getSegmentTags } from './segments';
import useContextMenu from './hooks/useContextMenu';
import { saveColor } from './colors';
import { getSegColors } from './util/colors';
@ -52,7 +51,7 @@ const Segment = memo(({ seg, index, currentSegIndex, formatTimecode, getFrameCou
{ type: 'separator' },
{ label: t('View tags'), enabled: Object.keys(getSegmentTags(seg)).length > 0, click: () => onViewSegmentTagsPress(seg) },
{ label: t('Segment tags'), click: () => onViewSegmentTagsPress(index) },
]);
const duration = seg.end - seg.start;

Wyświetl plik

@ -371,6 +371,20 @@ export async function showOpenAndMergeDialog({ defaultPath, onMergeClick }) {
showMergeDialog(filePaths, onMergeClick);
}
export async function showEditableJsonDialog({ text, title, inputLabel, inputValue, inputValidator }) {
const { value } = await Swal.fire({
input: 'textarea',
inputLabel,
text,
title,
inputPlaceholder: JSON5.stringify({ exampleTag: 'Example value' }, null, 2),
inputValue,
showCancelButton: true,
inputValidator,
});
return value;
}
export function showJson5Dialog({ title, json }) {
const html = (
<SyntaxHighlighter language="javascript" style={style} customStyle={{ textAlign: 'left', maxHeight: 300, overflowY: 'auto', fontSize: 14 }}>

Wyświetl plik

@ -1,11 +1,13 @@
import csvStringify from 'csv-stringify';
import pify from 'pify';
import JSON5 from 'json5';
import { parseCuesheet, parseXmeml, parseCsv, parsePbf, parseMplayerEdl } from './edlFormats';
import { formatDuration } from './util/duration';
const fs = window.require('fs-extra');
const cueParser = window.require('cue-parser');
const { basename } = window.require('path');
const csvStringifyAsync = pify(csvStringify);
@ -48,3 +50,16 @@ export async function saveTsv(path, cutSegments) {
const str = await csvStringifyAsync(mapSegments(cutSegments), { delimiter: '\t' });
await fs.writeFile(path, str);
}
export async function saveLlcProject({ savePath, filePath, cutSegments }) {
const projectData = {
version: 1,
mediaFileName: basename(filePath),
cutSegments: cutSegments.map(({ start, end, name, tags }) => ({ start, end, name, tags })),
};
await fs.writeFile(savePath, JSON5.stringify(projectData, null, 2));
}
export async function loadLlcProject(path) {
return JSON5.parse(await fs.readFile(path));
}

Wyświetl plik

@ -50,8 +50,12 @@ export async function checkDirWriteAccess(dirPath) {
return true;
}
export async function pathExists(pathIn) {
return fs.exists(pathIn);
}
export async function dirExists(dirPath) {
return (await fs.exists(dirPath)) && (await fs.lstat(dirPath)).isDirectory();
return (await pathExists(dirPath)) && (await fs.lstat(dirPath)).isDirectory();
}
export async function transferTimestamps(inPath, outPath, offset = 0) {
@ -145,6 +149,7 @@ export function getOutFileExtension({ isCustomFormatSelected, outFormat, filePat
return isCustomFormatSelected ? `.${getExtensionForFormat(outFormat)}` : path.extname(filePath);
}
// This is used as a fallback and so it has to always generate unique file names
// eslint-disable-next-line no-template-curly-in-string
export const defaultOutSegTemplate = '${FILENAME}-${CUT_FROM}-${CUT_TO}${SEG_SUFFIX}${EXT}';
@ -164,3 +169,6 @@ export function generateSegFileName({ template, inputFileNameWithoutExt, segSuff
}
export const hasDuplicates = (arr) => new Set(arr).size !== arr.length;
// Need to resolve relative paths from the command line https://github.com/mifi/lossless-cut/issues/639
export const resolvePathIfNeeded = (inPath) => (path.isAbsolute(path) ? path : path.resolve(inPath));