diff --git a/public/menu.js b/public/menu.js index 6aada661..158bc39c 100644 --- a/public/menu.js +++ b/public/menu.js @@ -52,11 +52,17 @@ module.exports = (app, mainWindow, newVersion) => { label: i18n.t('Import project'), submenu: [ { - label: i18n.t('LosslessCut (CSV)'), + label: i18n.t('Times in seconds (CSV)'), click() { mainWindow.webContents.send('importEdlFile', 'csv'); }, }, + { + label: i18n.t('Frame numbers (CSV)'), + click() { + mainWindow.webContents.send('importEdlFile', 'csv-frames'); + }, + }, { label: i18n.t('EDL (MPlayer)'), click() { @@ -93,7 +99,7 @@ module.exports = (app, mainWindow, newVersion) => { label: i18n.t('Export project'), submenu: [ { - label: i18n.t('LosslessCut (CSV)'), + label: i18n.t('Times in seconds (CSV)'), click() { mainWindow.webContents.send('exportEdlFile', 'csv'); }, diff --git a/src/App.jsx b/src/App.jsx index ac85f2c5..4d3935c2 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -53,7 +53,7 @@ import { getDuration, getTimecodeFromStreams, createChaptersFromSegments, extractSubtitleTrack, } from './ffmpeg'; import { exportEdlFile, readEdlFile, saveLlcProject, loadLlcProject, askForEdlImport } from './edlStore'; -import { formatYouTube } from './edlFormats'; +import { formatYouTube, getTimeFromFrameNum as getTimeFromFrameNumRaw, getFrameCountRaw } from './edlFormats'; import { getOutPath, toast, errorToast, handleError, setFileNameTitle, getOutDir, withBlur, checkDirWriteAccess, dirExists, openDirToast, isMasBuild, isStoreBuild, dragPreventer, doesPlayerSupportFile, @@ -446,10 +446,9 @@ const App = memo(() => { setCutSegments(sortBy(cutSegments, getSegApparentStart)); }, [cutSegments, setCutSegments]); - const getFrameCount = useCallback((sec) => { - if (detectedFps == null) return undefined; - return Math.floor(sec * detectedFps); - }, [detectedFps]); + const getFrameCount = useCallback((sec) => getFrameCountRaw(detectedFps, sec), [detectedFps]); + + const getTimeFromFrameNum = useCallback((frameNum) => getTimeFromFrameNumRaw(detectedFps, frameNum), [detectedFps]); const formatTimecode = useCallback(({ seconds, shorten }) => { if (timecodeFormat === 'frameCount') { @@ -1129,7 +1128,6 @@ const App = memo(() => { // Emulate a single segment with no cuts (full timeline) segmentsToExport = [{ start: 0, end: getSegApparentEnd({}) }]; chaptersToAdd = sortBy(enabledOutSegments, 'start').map((segment) => ({ start: segment.start, end: segment.end, name: segment.name })); - console.log(chaptersToAdd); } console.log('outSegTemplateOrDefault', outSegTemplateOrDefault); @@ -1911,7 +1909,7 @@ const App = memo(() => { if (!checkFileOpened()) return; try { - const edl = await askForEdlImport(type); + const edl = await askForEdlImport({ type, getTimeFromFrameNum }); if (edl.length > 0) loadCutSegments(edl, true); } catch (err) { handleError(err); @@ -2049,7 +2047,7 @@ const App = memo(() => { outputDir, filePath, customOutDir, startTimeOffset, userHtml5ifyCurrentFile, batchLoadPaths, extractAllStreams, userOpenFiles, openSendReportDialogWithState, setWorking, 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 () => { diff --git a/src/edlFormats.js b/src/edlFormats.js index a06a6c50..a7780dd9 100644 --- a/src/edlFormats.js +++ b/src/edlFormats.js @@ -2,6 +2,7 @@ import fastXmlParser from 'fast-xml-parser'; import i18n from 'i18next'; import csvParse from 'csv-parse/lib/browser'; +import csvStringify from 'csv-stringify/lib/browser'; import pify from 'pify'; import sortBy from 'lodash/sortBy'; @@ -10,16 +11,29 @@ import { formatDuration } from './util/duration'; import { invertSegments, sortSegments } from './segments'; const csvParseAsync = pify(csvParse); +const csvStringifyAsync = pify(csvStringify); -export async function parseCsv(str) { - const rows = await csvParseAsync(str, {}); +export const getTimeFromFrameNum = (detectedFps, frameNum) => frameNum / detectedFps; + +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.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 .map(([start, end, name]) => ({ - start: start === '' ? undefined : parseFloat(start, 10), - end: end === '' ? undefined : parseFloat(end, 10), + start: parseTimeVal(start), + end: parseTimeVal(end), name, })); @@ -168,3 +182,35 @@ export function formatYouTube(segments) { return `${timeStr}${namePart}`; }).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' }); +} diff --git a/src/edlFormats.test.js b/src/edlFormats.test.js index 7177cc81..bf62b9d2 100644 --- a/src/edlFormats.test.js +++ b/src/edlFormats.test.js @@ -1,7 +1,7 @@ import fs from 'fs'; 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); @@ -139,3 +139,29 @@ it('parses xmeml 1', async () => { it('parses xmeml 2', async () => { 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); +}); diff --git a/src/edlStore.js b/src/edlStore.js index 61099672..203a0aaf 100644 --- a/src/edlStore.js +++ b/src/edlStore.js @@ -1,10 +1,7 @@ -import csvStringify from 'csv-stringify/lib/browser'; -import pify from 'pify'; import JSON5 from 'json5'; import i18n from 'i18next'; -import { parseCuesheet, parseXmeml, parseCsv, parsePbf, parseMplayerEdl } from './edlFormats'; -import { formatDuration } from './util/duration'; +import { parseCuesheet, parseXmeml, parseCsv, parsePbf, parseMplayerEdl, formatCsvHuman, formatTsv, formatCsvFrames, formatCsvSeconds } from './edlFormats'; import { askForYouTubeInput } from './dialogs'; const fs = window.require('fs-extra'); @@ -14,12 +11,14 @@ const { basename } = window.require('path'); const electron = window.require('electron'); // eslint-disable-line const { dialog } = electron.remote; -const csvStringifyAsync = pify(csvStringify); - -export async function loadCsv(path) { +export async function loadCsvSeconds(path) { 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) { return parseXmeml(await fs.readFile(path, 'utf-8')); } @@ -37,38 +36,19 @@ export async function loadCue(path) { } export async function saveCsv(path, cutSegments) { - const rows = cutSegments.map(({ start, end, name }) => [start, end, name]); - const str = await csvStringifyAsync(rows); - await fs.writeFile(path, str); + await fs.writeFile(path, await formatCsvSeconds(cutSegments)); } -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) { - const str = await csvStringifyAsync(formatSegmentsTimes(cutSegments)); - await fs.writeFile(path, str); + await fs.writeFile(path, await formatCsvHuman(cutSegments)); } export async function saveCsvFrames({ path, cutSegments, getFrameCount }) { - const str = await csvStringifyAsync(formatSegmentsFrameCounts({ cutSegments, getFrameCount })); - await fs.writeFile(path, str); + await fs.writeFile(path, await formatCsvFrames({ cutSegments, getFrameCount })); } export async function saveTsv(path, cutSegments) { - const str = await csvStringifyAsync(formatSegmentsTimes(cutSegments), { delimiter: '\t' }); - await fs.writeFile(path, str); + await fs.writeFile(path, await formatTsv(cutSegments)); } export async function saveLlcProject({ savePath, filePath, cutSegments }) { @@ -84,8 +64,9 @@ export async function loadLlcProject(path) { return JSON5.parse(await fs.readFile(path)); } -export async function readEdlFile({ type, path }) { - if (type === 'csv') return loadCsv(path); +export async function readEdlFile({ type, path, getTimeFromFrameNum }) { + if (type === 'csv') return loadCsvSeconds(path); + if (type === 'csv-frames') return loadCsvFrames(path, getTimeFromFrameNum); if (type === 'xmeml') return loadXmeml(path); if (type === 'cue') return loadCue(path); if (type === 'pbf') return loadPbf(path); @@ -97,11 +78,11 @@ export async function readEdlFile({ type, path }) { throw new Error('Invalid EDL type'); } -export async function askForEdlImport(type) { +export async function askForEdlImport({ type, getTimeFromFrameNum }) { if (type === 'youtube') return askForYouTubeInput(); 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 === 'cue') filters = [{ name: i18n.t('CUE files'), extensions: ['cue'] }]; 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 }); 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 }) {