kopia lustrzana https://github.com/mifi/lossless-cut
implement import csv with frame numbers #1024
rodzic
3b3444fc7e
commit
fe88b3d6f6
|
@ -52,11 +52,17 @@ module.exports = (app, mainWindow, newVersion) => {
|
||||||
label: i18n.t('Import project'),
|
label: i18n.t('Import project'),
|
||||||
submenu: [
|
submenu: [
|
||||||
{
|
{
|
||||||
label: i18n.t('LosslessCut (CSV)'),
|
label: i18n.t('Times in seconds (CSV)'),
|
||||||
click() {
|
click() {
|
||||||
mainWindow.webContents.send('importEdlFile', 'csv');
|
mainWindow.webContents.send('importEdlFile', 'csv');
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: i18n.t('Frame numbers (CSV)'),
|
||||||
|
click() {
|
||||||
|
mainWindow.webContents.send('importEdlFile', 'csv-frames');
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: i18n.t('EDL (MPlayer)'),
|
label: i18n.t('EDL (MPlayer)'),
|
||||||
click() {
|
click() {
|
||||||
|
@ -93,7 +99,7 @@ module.exports = (app, mainWindow, newVersion) => {
|
||||||
label: i18n.t('Export project'),
|
label: i18n.t('Export project'),
|
||||||
submenu: [
|
submenu: [
|
||||||
{
|
{
|
||||||
label: i18n.t('LosslessCut (CSV)'),
|
label: i18n.t('Times in seconds (CSV)'),
|
||||||
click() {
|
click() {
|
||||||
mainWindow.webContents.send('exportEdlFile', 'csv');
|
mainWindow.webContents.send('exportEdlFile', 'csv');
|
||||||
},
|
},
|
||||||
|
|
14
src/App.jsx
14
src/App.jsx
|
@ -53,7 +53,7 @@ import {
|
||||||
getDuration, getTimecodeFromStreams, createChaptersFromSegments, extractSubtitleTrack,
|
getDuration, getTimecodeFromStreams, createChaptersFromSegments, extractSubtitleTrack,
|
||||||
} from './ffmpeg';
|
} from './ffmpeg';
|
||||||
import { exportEdlFile, readEdlFile, saveLlcProject, loadLlcProject, askForEdlImport } from './edlStore';
|
import { exportEdlFile, readEdlFile, saveLlcProject, loadLlcProject, askForEdlImport } from './edlStore';
|
||||||
import { formatYouTube } from './edlFormats';
|
import { formatYouTube, getTimeFromFrameNum as getTimeFromFrameNumRaw, getFrameCountRaw } from './edlFormats';
|
||||||
import {
|
import {
|
||||||
getOutPath, toast, errorToast, handleError, setFileNameTitle, getOutDir, withBlur,
|
getOutPath, toast, errorToast, handleError, setFileNameTitle, getOutDir, withBlur,
|
||||||
checkDirWriteAccess, dirExists, openDirToast, isMasBuild, isStoreBuild, dragPreventer, doesPlayerSupportFile,
|
checkDirWriteAccess, dirExists, openDirToast, isMasBuild, isStoreBuild, dragPreventer, doesPlayerSupportFile,
|
||||||
|
@ -446,10 +446,9 @@ const App = memo(() => {
|
||||||
setCutSegments(sortBy(cutSegments, getSegApparentStart));
|
setCutSegments(sortBy(cutSegments, getSegApparentStart));
|
||||||
}, [cutSegments, setCutSegments]);
|
}, [cutSegments, setCutSegments]);
|
||||||
|
|
||||||
const getFrameCount = useCallback((sec) => {
|
const getFrameCount = useCallback((sec) => getFrameCountRaw(detectedFps, sec), [detectedFps]);
|
||||||
if (detectedFps == null) return undefined;
|
|
||||||
return Math.floor(sec * detectedFps);
|
const getTimeFromFrameNum = useCallback((frameNum) => getTimeFromFrameNumRaw(detectedFps, frameNum), [detectedFps]);
|
||||||
}, [detectedFps]);
|
|
||||||
|
|
||||||
const formatTimecode = useCallback(({ seconds, shorten }) => {
|
const formatTimecode = useCallback(({ seconds, shorten }) => {
|
||||||
if (timecodeFormat === 'frameCount') {
|
if (timecodeFormat === 'frameCount') {
|
||||||
|
@ -1129,7 +1128,6 @@ const App = memo(() => {
|
||||||
// Emulate a single segment with no cuts (full timeline)
|
// Emulate a single segment with no cuts (full timeline)
|
||||||
segmentsToExport = [{ start: 0, end: getSegApparentEnd({}) }];
|
segmentsToExport = [{ start: 0, end: getSegApparentEnd({}) }];
|
||||||
chaptersToAdd = sortBy(enabledOutSegments, 'start').map((segment) => ({ start: segment.start, end: segment.end, name: segment.name }));
|
chaptersToAdd = sortBy(enabledOutSegments, 'start').map((segment) => ({ start: segment.start, end: segment.end, name: segment.name }));
|
||||||
console.log(chaptersToAdd);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('outSegTemplateOrDefault', outSegTemplateOrDefault);
|
console.log('outSegTemplateOrDefault', outSegTemplateOrDefault);
|
||||||
|
@ -1911,7 +1909,7 @@ const App = memo(() => {
|
||||||
if (!checkFileOpened()) return;
|
if (!checkFileOpened()) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const edl = await askForEdlImport(type);
|
const edl = await askForEdlImport({ type, getTimeFromFrameNum });
|
||||||
if (edl.length > 0) loadCutSegments(edl, true);
|
if (edl.length > 0) loadCutSegments(edl, true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handleError(err);
|
handleError(err);
|
||||||
|
@ -2049,7 +2047,7 @@ const App = memo(() => {
|
||||||
outputDir, filePath, customOutDir, startTimeOffset, userHtml5ifyCurrentFile, batchLoadPaths,
|
outputDir, filePath, customOutDir, startTimeOffset, userHtml5ifyCurrentFile, batchLoadPaths,
|
||||||
extractAllStreams, userOpenFiles, openSendReportDialogWithState, setWorking,
|
extractAllStreams, userOpenFiles, openSendReportDialogWithState, setWorking,
|
||||||
loadEdlFile, cutSegments, apparentCutSegments, edlFilePath, toggleHelp, toggleSettings, ensureOutDirAccessible, html5ifyAndLoad, html5ify,
|
loadEdlFile, cutSegments, apparentCutSegments, edlFilePath, toggleHelp, toggleSettings, ensureOutDirAccessible, html5ifyAndLoad, html5ify,
|
||||||
loadCutSegments, duration, checkFileOpened, loadMedia, fileFormat, reorderSegsByStartTime, closeFileWithConfirm, closeBatch, clearSegments, clearSegCounter, fixInvalidDuration, invertAllCutSegments, getFrameCount,
|
loadCutSegments, duration, checkFileOpened, loadMedia, fileFormat, reorderSegsByStartTime, closeFileWithConfirm, closeBatch, clearSegments, clearSegCounter, fixInvalidDuration, invertAllCutSegments, getFrameCount, getTimeFromFrameNum,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const showAddStreamSourceDialog = useCallback(async () => {
|
const showAddStreamSourceDialog = useCallback(async () => {
|
||||||
|
|
|
@ -2,6 +2,7 @@ import fastXmlParser from 'fast-xml-parser';
|
||||||
import i18n from 'i18next';
|
import i18n from 'i18next';
|
||||||
|
|
||||||
import csvParse from 'csv-parse/lib/browser';
|
import csvParse from 'csv-parse/lib/browser';
|
||||||
|
import csvStringify from 'csv-stringify/lib/browser';
|
||||||
import pify from 'pify';
|
import pify from 'pify';
|
||||||
import sortBy from 'lodash/sortBy';
|
import sortBy from 'lodash/sortBy';
|
||||||
|
|
||||||
|
@ -10,16 +11,29 @@ import { formatDuration } from './util/duration';
|
||||||
import { invertSegments, sortSegments } from './segments';
|
import { invertSegments, sortSegments } from './segments';
|
||||||
|
|
||||||
const csvParseAsync = pify(csvParse);
|
const csvParseAsync = pify(csvParse);
|
||||||
|
const csvStringifyAsync = pify(csvStringify);
|
||||||
|
|
||||||
export async function parseCsv(str) {
|
export const getTimeFromFrameNum = (detectedFps, frameNum) => frameNum / detectedFps;
|
||||||
const rows = await csvParseAsync(str, {});
|
|
||||||
|
export function getFrameCountRaw(detectedFps, sec) {
|
||||||
|
if (detectedFps == null) return undefined;
|
||||||
|
return Math.floor(sec * detectedFps);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function parseCsv(csvStr, processTime = (t) => t) {
|
||||||
|
const rows = await csvParseAsync(csvStr, {});
|
||||||
if (rows.length === 0) throw new Error(i18n.t('No rows found'));
|
if (rows.length === 0) throw new Error(i18n.t('No rows found'));
|
||||||
if (!rows.every(row => row.length === 3)) throw new Error(i18n.t('One or more rows does not have 3 columns'));
|
if (!rows.every(row => row.length === 3)) throw new Error(i18n.t('One or more rows does not have 3 columns'));
|
||||||
|
|
||||||
|
function parseTimeVal(str) {
|
||||||
|
if (str === '') return undefined;
|
||||||
|
const parsed = parseFloat(str, 10);
|
||||||
|
return processTime(parsed);
|
||||||
|
}
|
||||||
const mapped = rows
|
const mapped = rows
|
||||||
.map(([start, end, name]) => ({
|
.map(([start, end, name]) => ({
|
||||||
start: start === '' ? undefined : parseFloat(start, 10),
|
start: parseTimeVal(start),
|
||||||
end: end === '' ? undefined : parseFloat(end, 10),
|
end: parseTimeVal(end),
|
||||||
name,
|
name,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
@ -168,3 +182,35 @@ export function formatYouTube(segments) {
|
||||||
return `${timeStr}${namePart}`;
|
return `${timeStr}${namePart}`;
|
||||||
}).join('\n');
|
}).join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const safeFormatDuration = (duration) => (duration != null ? formatDuration({ seconds: duration }) : '');
|
||||||
|
const safeFormatFrameCount = ({ seconds, getFrameCount }) => (seconds != null ? getFrameCount(seconds) : '');
|
||||||
|
|
||||||
|
export const formatSegmentsTimes = (cutSegments) => cutSegments.map(({ start, end, name }) => [
|
||||||
|
safeFormatDuration(start),
|
||||||
|
safeFormatDuration(end),
|
||||||
|
name,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const formatSegmentsFrameCounts = ({ cutSegments, getFrameCount }) => cutSegments.map(({ start, end, name }) => [
|
||||||
|
safeFormatFrameCount({ seconds: start, getFrameCount }),
|
||||||
|
safeFormatFrameCount({ seconds: end, getFrameCount }),
|
||||||
|
name,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export async function formatCsvFrames({ cutSegments, getFrameCount }) {
|
||||||
|
return csvStringifyAsync(formatSegmentsFrameCounts({ cutSegments, getFrameCount }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function formatCsvSeconds(cutSegments) {
|
||||||
|
const rows = cutSegments.map(({ start, end, name }) => [start, end, name]);
|
||||||
|
return csvStringifyAsync(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function formatCsvHuman(cutSegments) {
|
||||||
|
return csvStringifyAsync(formatSegmentsTimes(cutSegments));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function formatTsv(cutSegments) {
|
||||||
|
return csvStringifyAsync(formatSegmentsTimes(cutSegments), { delimiter: '\t' });
|
||||||
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
|
||||||
import { parseYouTube, formatYouTube, parseMplayerEdl, parseXmeml } from './edlFormats';
|
import { parseYouTube, formatYouTube, parseMplayerEdl, parseXmeml, parseCsv, getTimeFromFrameNum, formatCsvFrames, getFrameCountRaw } from './edlFormats';
|
||||||
|
|
||||||
const readFixture = async (name, encoding = 'utf-8') => fs.promises.readFile(join(__dirname, 'fixtures', name), encoding);
|
const readFixture = async (name, encoding = 'utf-8') => fs.promises.readFile(join(__dirname, 'fixtures', name), encoding);
|
||||||
|
|
||||||
|
@ -139,3 +139,29 @@ it('parses xmeml 1', async () => {
|
||||||
it('parses xmeml 2', async () => {
|
it('parses xmeml 2', async () => {
|
||||||
expect(await parseXmeml(await readFixture('Final Cut Pro XMEML 2.xml'))).toMatchSnapshot();
|
expect(await parseXmeml(await readFixture('Final Cut Pro XMEML 2.xml'))).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// https://github.com/mifi/lossless-cut/issues/1024
|
||||||
|
const csvFramesStr = `\
|
||||||
|
0,155,EP106_SQ010_SH0010
|
||||||
|
156,251,EP106_SQ010_SH0020
|
||||||
|
252,687,EP106_SQ010_SH0030
|
||||||
|
688,747,EP106_SQ020_SH0010
|
||||||
|
`;
|
||||||
|
|
||||||
|
it('parses csv with frames', async () => {
|
||||||
|
const fps = 30;
|
||||||
|
const parsed = await parseCsv(csvFramesStr, (frameCount) => getTimeFromFrameNum(fps, frameCount));
|
||||||
|
|
||||||
|
expect(parsed).toEqual([
|
||||||
|
{ end: 5.166666666666667, name: 'EP106_SQ010_SH0010', start: 0 },
|
||||||
|
{ end: 8.366666666666667, name: 'EP106_SQ010_SH0020', start: 5.2 },
|
||||||
|
{ end: 22.9, name: 'EP106_SQ010_SH0030', start: 8.4 },
|
||||||
|
{ end: 24.9, name: 'EP106_SQ020_SH0010', start: 22.933333333333334 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const formatted = await formatCsvFrames({
|
||||||
|
cutSegments: parsed,
|
||||||
|
getFrameCount: (sec) => getFrameCountRaw(fps, sec),
|
||||||
|
});
|
||||||
|
expect(formatted).toEqual(csvFramesStr);
|
||||||
|
});
|
||||||
|
|
|
@ -1,10 +1,7 @@
|
||||||
import csvStringify from 'csv-stringify/lib/browser';
|
|
||||||
import pify from 'pify';
|
|
||||||
import JSON5 from 'json5';
|
import JSON5 from 'json5';
|
||||||
import i18n from 'i18next';
|
import i18n from 'i18next';
|
||||||
|
|
||||||
import { parseCuesheet, parseXmeml, parseCsv, parsePbf, parseMplayerEdl } from './edlFormats';
|
import { parseCuesheet, parseXmeml, parseCsv, parsePbf, parseMplayerEdl, formatCsvHuman, formatTsv, formatCsvFrames, formatCsvSeconds } from './edlFormats';
|
||||||
import { formatDuration } from './util/duration';
|
|
||||||
import { askForYouTubeInput } from './dialogs';
|
import { askForYouTubeInput } from './dialogs';
|
||||||
|
|
||||||
const fs = window.require('fs-extra');
|
const fs = window.require('fs-extra');
|
||||||
|
@ -14,12 +11,14 @@ const { basename } = window.require('path');
|
||||||
const electron = window.require('electron'); // eslint-disable-line
|
const electron = window.require('electron'); // eslint-disable-line
|
||||||
const { dialog } = electron.remote;
|
const { dialog } = electron.remote;
|
||||||
|
|
||||||
const csvStringifyAsync = pify(csvStringify);
|
export async function loadCsvSeconds(path) {
|
||||||
|
|
||||||
export async function loadCsv(path) {
|
|
||||||
return parseCsv(await fs.readFile(path, 'utf-8'));
|
return parseCsv(await fs.readFile(path, 'utf-8'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function loadCsvFrames(path, getTimeFromFrameNum) {
|
||||||
|
return parseCsv(await fs.readFile(path, 'utf-8'), (frameNum) => getTimeFromFrameNum(frameNum));
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadXmeml(path) {
|
export async function loadXmeml(path) {
|
||||||
return parseXmeml(await fs.readFile(path, 'utf-8'));
|
return parseXmeml(await fs.readFile(path, 'utf-8'));
|
||||||
}
|
}
|
||||||
|
@ -37,38 +36,19 @@ export async function loadCue(path) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveCsv(path, cutSegments) {
|
export async function saveCsv(path, cutSegments) {
|
||||||
const rows = cutSegments.map(({ start, end, name }) => [start, end, name]);
|
await fs.writeFile(path, await formatCsvSeconds(cutSegments));
|
||||||
const str = await csvStringifyAsync(rows);
|
|
||||||
await fs.writeFile(path, str);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const safeFormatDuration = (duration) => (duration != null ? formatDuration({ seconds: duration }) : '');
|
|
||||||
const safeFormatFrameCount = ({ seconds, getFrameCount }) => (seconds != null ? getFrameCount(seconds) : '');
|
|
||||||
|
|
||||||
const formatSegmentsTimes = (cutSegments) => cutSegments.map(({ start, end, name }) => [
|
|
||||||
safeFormatDuration(start),
|
|
||||||
safeFormatDuration(end),
|
|
||||||
name,
|
|
||||||
]);
|
|
||||||
const formatSegmentsFrameCounts = ({ cutSegments, getFrameCount }) => cutSegments.map(({ start, end, name }) => [
|
|
||||||
safeFormatFrameCount({ seconds: start, getFrameCount }),
|
|
||||||
safeFormatFrameCount({ seconds: end, getFrameCount }),
|
|
||||||
name,
|
|
||||||
]);
|
|
||||||
|
|
||||||
export async function saveCsvHuman(path, cutSegments) {
|
export async function saveCsvHuman(path, cutSegments) {
|
||||||
const str = await csvStringifyAsync(formatSegmentsTimes(cutSegments));
|
await fs.writeFile(path, await formatCsvHuman(cutSegments));
|
||||||
await fs.writeFile(path, str);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveCsvFrames({ path, cutSegments, getFrameCount }) {
|
export async function saveCsvFrames({ path, cutSegments, getFrameCount }) {
|
||||||
const str = await csvStringifyAsync(formatSegmentsFrameCounts({ cutSegments, getFrameCount }));
|
await fs.writeFile(path, await formatCsvFrames({ cutSegments, getFrameCount }));
|
||||||
await fs.writeFile(path, str);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveTsv(path, cutSegments) {
|
export async function saveTsv(path, cutSegments) {
|
||||||
const str = await csvStringifyAsync(formatSegmentsTimes(cutSegments), { delimiter: '\t' });
|
await fs.writeFile(path, await formatTsv(cutSegments));
|
||||||
await fs.writeFile(path, str);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveLlcProject({ savePath, filePath, cutSegments }) {
|
export async function saveLlcProject({ savePath, filePath, cutSegments }) {
|
||||||
|
@ -84,8 +64,9 @@ export async function loadLlcProject(path) {
|
||||||
return JSON5.parse(await fs.readFile(path));
|
return JSON5.parse(await fs.readFile(path));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readEdlFile({ type, path }) {
|
export async function readEdlFile({ type, path, getTimeFromFrameNum }) {
|
||||||
if (type === 'csv') return loadCsv(path);
|
if (type === 'csv') return loadCsvSeconds(path);
|
||||||
|
if (type === 'csv-frames') return loadCsvFrames(path, getTimeFromFrameNum);
|
||||||
if (type === 'xmeml') return loadXmeml(path);
|
if (type === 'xmeml') return loadXmeml(path);
|
||||||
if (type === 'cue') return loadCue(path);
|
if (type === 'cue') return loadCue(path);
|
||||||
if (type === 'pbf') return loadPbf(path);
|
if (type === 'pbf') return loadPbf(path);
|
||||||
|
@ -97,11 +78,11 @@ export async function readEdlFile({ type, path }) {
|
||||||
throw new Error('Invalid EDL type');
|
throw new Error('Invalid EDL type');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function askForEdlImport(type) {
|
export async function askForEdlImport({ type, getTimeFromFrameNum }) {
|
||||||
if (type === 'youtube') return askForYouTubeInput();
|
if (type === 'youtube') return askForYouTubeInput();
|
||||||
|
|
||||||
let filters;
|
let filters;
|
||||||
if (type === 'csv') filters = [{ name: i18n.t('CSV files'), extensions: ['csv'] }];
|
if (type === 'csv' || type === 'csv-frames') filters = [{ name: i18n.t('CSV files'), extensions: ['csv'] }];
|
||||||
else if (type === 'xmeml') filters = [{ name: i18n.t('XML files'), extensions: ['xml'] }];
|
else if (type === 'xmeml') filters = [{ name: i18n.t('XML files'), extensions: ['xml'] }];
|
||||||
else if (type === 'cue') filters = [{ name: i18n.t('CUE files'), extensions: ['cue'] }];
|
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 === 'pbf') filters = [{ name: i18n.t('PBF files'), extensions: ['pbf'] }];
|
||||||
|
@ -110,7 +91,7 @@ export async function askForEdlImport(type) {
|
||||||
|
|
||||||
const { canceled, filePaths } = await dialog.showOpenDialog({ properties: ['openFile'], filters });
|
const { canceled, filePaths } = await dialog.showOpenDialog({ properties: ['openFile'], filters });
|
||||||
if (canceled || filePaths.length < 1) return [];
|
if (canceled || filePaths.length < 1) return [];
|
||||||
return readEdlFile({ type, path: filePaths[0] });
|
return readEdlFile({ type, path: filePaths[0], getTimeFromFrameNum });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function exportEdlFile({ type, cutSegments, filePath, getFrameCount }) {
|
export async function exportEdlFile({ type, cutSegments, filePath, getFrameCount }) {
|
||||||
|
|
Ładowanie…
Reference in New Issue