diff --git a/src/edlFormats.js b/src/edlFormats.js index 4d2946c1..6796c99d 100644 --- a/src/edlFormats.js +++ b/src/edlFormats.js @@ -19,28 +19,42 @@ export function getFrameCountRaw(detectedFps, sec) { return Math.round(sec * detectedFps); } -export async function parseCsv(csvStr, processTime = (t) => t) { +function parseTime(str) { + const timeMatch = str.match(/^[^0-9]*(?:(?:([0-9]{1,}):)?([0-9]{1,2}):)?([0-9]{1,})(?:\.([0-9]{1,3}))?:?/); + if (!timeMatch) return undefined; + + const rest = str.substring(timeMatch[0].length); + + const [, hourStr, minStr, secStr, msStr] = timeMatch; + const hour = hourStr != null ? parseInt(hourStr, 10) : 0; + const min = minStr != null ? parseInt(minStr, 10) : 0; + const sec = parseFloat(msStr != null ? `${secStr}.${msStr}` : secStr); + + const time = (((hour * 60) + min) * 60 + sec); + return { time, rest }; +} + +export function parseCsvTime(str) { + const parsed = parseTime(str.trim()); + return parsed?.time; +} + +export const getFrameValParser = (fps) => (str) => { + if (str === '') return undefined; + const frameCount = parseFloat(str); + return getTimeFromFrameNum(fps, frameCount); +}; + +export async function parseCsv(csvStr, parseTimeFn) { 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; - let timestampMatch = str.match(/^(\d{1,2}):(\d{1,2}):(\d{1,2})(?:\.(\d{1,3}))?$/); - let parsed = undefined; - if (timestampMatch && timestampMatch.length === 5) { - let [, h, m, s, ms] = timestampMatch; - parsed = parseInt(h, 10) * 60 + parseInt(m, 10) * 60 + parseInt(s, 10) + parseInt(ms, 10) / 1000; - } else { - parsed = parseFloat(str, 10); - } - return processTime(parsed); - } const mapped = rows .map(([start, end, name]) => ({ - start: parseTimeVal(start), - end: parseTimeVal(end), - name, + start: parseTimeFn(start), + end: parseTimeFn(end), + name: name.trim(), })); if (!mapped.every(({ start, end }) => ( @@ -92,7 +106,7 @@ export function parseCuesheet(cuesheet) { const { tracks } = cuesheet.files[0]; - function parseTime(track) { + function getTime(track) { const index = track.indexes[0]; if (!index) return undefined; const { time } = index; @@ -103,9 +117,9 @@ export function parseCuesheet(cuesheet) { return tracks.map((track, i) => { const nextTrack = tracks[i + 1]; - const end = nextTrack && parseTime(nextTrack); + const end = nextTrack && getTime(nextTrack); - return { name: track.title, start: parseTime(track), end, tags: { performer: track.performer, title: track.title } }; + return { name: track.title, start: getTime(track), end, tags: { performer: track.performer, title: track.title } }; }); } @@ -168,37 +182,36 @@ export function parseFcpXml(xmlStr) { const { fcpxml } = xml; if (!fcpxml) throw Error('Root element not found in file'); - function parseTime(str) { + function getTime(str) { const match = str.match(/([0-9]+)\/([0-9]+)s/); if (!match) throw new Error('Invalid attribute'); return parseInt(match[1], 10) / parseInt(match[2], 10); } return fcpxml.library.event.project.sequence.spine['asset-clip'].map((assetClip) => { - const start = parseTime(assetClip['@_start']); - const duration = parseTime(assetClip['@_duration']); + const start = getTime(assetClip['@_start']); + const duration = getTime(assetClip['@_duration']); const end = start + duration; return { start, end }; }); } -export function parseYouTube(str) { - function parseLine(match) { - if (!match) return undefined; - const [, hourStr, minStr, secStr, msStr, name] = match; - const hour = hourStr != null ? parseInt(hourStr, 10) : 0; - const min = parseInt(minStr, 10); - const sec = parseInt(secStr, 10); - const ms = msStr != null ? parseInt(msStr, 10) : 0; - const time = (((hour * 60) + min) * 60 + sec) + ms / 1000; +export function parseYouTube(str) { + function parseLine(lineStr) { + const timeParsed = parseTime(lineStr); + if (timeParsed == null) return undefined; + + const { time, rest } = timeParsed; + + const nameMatch = rest.match(/^[\s-]+([^\n]*)$/); + if (!nameMatch) return undefined; + + const [, name] = nameMatch; return { time, name }; } - const lines = str.split('\n').map((lineStr) => { - const match = lineStr.match(/^[^0-9]*(?:([0-9]{1,}):)?([0-9]{1,2}):([0-9]{1,2})(?:\.([0-9]{3}))?:?[\s-]+([^\n]*)$/); - return parseLine(match); - }).filter((line) => line); + const lines = str.split('\n').map(parseLine).filter((line) => line); const linesSorted = sortBy(lines, (l) => l.time); diff --git a/src/edlFormats.test.js b/src/edlFormats.test.js index d8bbb67c..de2f908c 100644 --- a/src/edlFormats.test.js +++ b/src/edlFormats.test.js @@ -4,7 +4,7 @@ import { fileURLToPath } from 'url'; import { it, describe, expect } from 'vitest'; -import { parseYouTube, formatYouTube, parseMplayerEdl, parseXmeml, parseFcpXml, parseCsv, getTimeFromFrameNum, formatCsvFrames, formatCsvHuman, getFrameCountRaw, parsePbf, parseDvAnalyzerSummaryTxt } from './edlFormats'; +import { parseYouTube, formatYouTube, parseMplayerEdl, parseXmeml, parseFcpXml, parseCsv, parseCsvTime, getFrameValParser, formatCsvFrames, getFrameCountRaw, parsePbf, parseDvAnalyzerSummaryTxt } from './edlFormats'; // eslint-disable-next-line no-underscore-dangle const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -16,10 +16,13 @@ const expectYouTube1 = [ { 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: '' }, + { start: 5, end: 6, name: '' }, + { start: 6, end: 6.01, name: '6 label' }, + { start: 6.01, end: 61, name: '6.01 label' }, { start: 61, end: 61.012, name: 'Test 3' }, { start: 61.012, end: 62.012, name: 'Test 6' }, - { start: 62.012, end: 3661.012, name: 'Test 7' }, + { start: 62.012, end: 132, name: 'Test 7' }, + { start: 132, end: 3661.012, name: 'Integer' }, { start: 3661.012, end: 10074, name: 'Test - 4' }, { start: 10074, end: undefined, name: 'Short - hour and hyphen' }, ]; @@ -40,10 +43,12 @@ describe('parseYouTube', () => { 00:57:01.0123 Invalid 2 00:57:01. Invalid 3 01:15:: Invalid 4 - 0132 Invalid 5 + 0132 Integer 00:03 00:04 00:05 + 6 6 label + 6.01 6.01 label `; const edl = parseYouTube(str); expect(edl).toEqual(expectYouTube1); @@ -91,9 +96,12 @@ it('formatYouTube 2', () => { '0:02 00:57 double', '0:04', '0:05', + '0:06 6 label', + '0:06 6.01 label', '1:01 Test 3', '1:01 Test 6', '1:02 Test 7', + '2:12 Integer', '1:01:01 Test - 4', '2:47:54 Short - hour and hyphen', ]); @@ -195,7 +203,7 @@ const csvFramesStr = `\ it('parses csv with frames', async () => { const fps = 30; - const parsed = await parseCsv(csvFramesStr, (frameCount) => getTimeFromFrameNum(fps, frameCount)); + const parsed = await parseCsv(csvFramesStr, getFrameValParser(fps)); expect(parsed).toEqual([ { end: 5.166666666666667, name: 'EP106_SQ010_SH0010', start: 0 }, @@ -213,22 +221,24 @@ it('parses csv with frames', async () => { const csvTimestampStr = `\ 00:01:54.612,00:03:09.053,A -00:05:00.448,00:07:56.194,B + 00:05:00.448,00:07:56.194,B 00:09:27.075,00:11:44.264,C +0,1,D +1.01,1.99,E +0:2,0:3,F `; it('parses csv with timestamps', async () => { - const fps = 30; - const parsed = await parseCsv(csvTimestampStr); + const parsed = await parseCsv(csvTimestampStr, parseCsvTime); expect(parsed).toEqual([ - { end: 189.053, name: 'A', start: 114.612}, - { end: 476.194, name: 'B', start: 300.448}, - { end: 704.264, name: 'C', start: 567.075}, + { end: 189.053, name: 'A', start: 114.612 }, + { end: 476.194, name: 'B', start: 300.448 }, + { end: 704.264, name: 'C', start: 567.075 }, + { end: 1, name: 'D', start: 0 }, + { end: 1.99, name: 'E', start: 1.01 }, + { start: 2, name: 'F', end: 3 }, ]); - - const formatted = await formatCsvHuman(parsed); - expect(formatted).toEqual(csvTimestampStr); }); it('parses pbf', async () => { diff --git a/src/edlStore.js b/src/edlStore.js index 843e56bd..789d89c9 100644 --- a/src/edlStore.js +++ b/src/edlStore.js @@ -1,7 +1,7 @@ import JSON5 from 'json5'; import i18n from 'i18next'; -import { parseCuesheet, parseXmeml, parseFcpXml, parseCsv, parsePbf, parseMplayerEdl, formatCsvHuman, formatTsv, formatCsvFrames, formatCsvSeconds, getTimeFromFrameNum, parseDvAnalyzerSummaryTxt } from './edlFormats'; +import { parseCuesheet, parseXmeml, parseFcpXml, parseCsv, parsePbf, parseMplayerEdl, formatCsvHuman, formatTsv, formatCsvFrames, formatCsvSeconds, parseCsvTime, getFrameValParser, parseDvAnalyzerSummaryTxt } from './edlFormats'; import { askForYouTubeInput, showOpenDialog } from './dialogs'; import { getOutPath } from './util'; @@ -12,12 +12,12 @@ const { basename } = window.require('path'); const { dialog } = window.require('@electron/remote'); export async function loadCsvSeconds(path) { - return parseCsv(await fs.readFile(path, 'utf-8')); + return parseCsv(await fs.readFile(path, 'utf-8'), parseCsvTime); } export async function loadCsvFrames(path, fps) { if (!fps) throw new Error('The loaded file has an unknown framerate'); - return parseCsv(await fs.readFile(path, 'utf-8'), (frameNum) => getTimeFromFrameNum(fps, frameNum)); + return parseCsv(await fs.readFile(path, 'utf-8'), getFrameValParser(fps)); } export async function loadXmeml(path) {