diff --git a/public/menu.js b/public/menu.js index 57a5050..a1cc3d0 100644 --- a/public/menu.js +++ b/public/menu.js @@ -100,6 +100,12 @@ module.exports = ({ app, mainWindow, newVersion, isStoreBuild }) => { mainWindow.webContents.send('importEdlFile', 'pbf'); }, }, + { + label: esc(t('Subtitles (SRT)')), + click() { + mainWindow.webContents.send('importEdlFile', 'srt'); + }, + }, { label: esc(t('DV Analyzer Summary.txt')), click() { @@ -135,6 +141,12 @@ module.exports = ({ app, mainWindow, newVersion, isStoreBuild }) => { mainWindow.webContents.send('exportEdlFile', 'tsv-human'); }, }, + { + label: esc(t('Subtitles (SRT)')), + click() { + mainWindow.webContents.send('exportEdlFile', 'srt'); + }, + }, { label: esc(t('Start times as YouTube Chapters')), click() { diff --git a/src/App.jsx b/src/App.jsx index 4a53896..faf9eb6 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1825,8 +1825,10 @@ const App = memo(() => { const inputOptions = { open: isFileOpened ? i18n.t('Open the file instead of the current one') : i18n.t('Open the file'), }; + if (isFileOpened) { if (isLlcProject) inputOptions.project = i18n.t('Load segments from the new file, but keep the current media'); + else if (filePathLowerCase.endsWith('.srt')) inputOptions.subtitles = i18n.t('Convert subtitiles into segments'); else inputOptions.tracks = i18n.t('Include all tracks from the new file'); } @@ -1844,6 +1846,10 @@ const App = memo(() => { await loadEdlFile({ path: firstFilePath, type: 'llc' }); return; } + if (openFileResponse === 'subtitles') { + await loadEdlFile({ path: firstFilePath, type: 'srt' }); + return; + } if (openFileResponse === 'tracks') { await addStreamSourceFile(firstFilePath); setStreamsSelectorShown(true); @@ -1861,6 +1867,7 @@ const App = memo(() => { if (batchPaths.size > 1) setConcatDialogVisible(true); return; } + // Dialog canceled: return; } diff --git a/src/__snapshots__/edlFormats.test.js.snap b/src/__snapshots__/edlFormats.test.js.snap index 7ae4b98..c9350b6 100644 --- a/src/__snapshots__/edlFormats.test.js.snap +++ b/src/__snapshots__/edlFormats.test.js.snap @@ -1,5 +1,17 @@ // Vitest Snapshot v1 +exports[`format srt 1`] = ` +"1 +00:00:02,000 --> 00:00:06,000 +First subtitle + +2 +00:00:28,967 --> 01:30:30,950 +Subtitle 2 line 1 +Subtitle 2 line 2 +" +`; + exports[`parses DV Analyzer Summary.txt 1`] = ` [ { @@ -1013,6 +1025,28 @@ exports[`parses pbf 4`] = ` ] `; +exports[`parses srt 1`] = ` +[ + { + "end": 6, + "name": "First subtitle", + "start": 2, + "tags": { + "index": 1, + }, + }, + { + "end": 5430.95, + "name": "Subtitle 2 line 1 +Subtitle 2 line 2", + "start": 28.967, + "tags": { + "index": 2, + }, + }, +] +`; + exports[`parses xmeml - with multiple tracks 1`] = ` [ { diff --git a/src/dialogs/index.jsx b/src/dialogs/index.jsx index 52bfdb2..98c5c56 100644 --- a/src/dialogs/index.jsx +++ b/src/dialogs/index.jsx @@ -509,7 +509,7 @@ export async function labelSegmentDialog({ currentName, maxLength }) { showCancelButton: true, title: i18n.t('Label current segment'), inputValue: currentName, - input: 'text', + input: currentName.includes('\n') ? 'textarea' : 'text', inputValidator: (v) => (v.length > maxLength ? `${i18n.t('Max length')} ${maxLength}` : undefined), }); return value; diff --git a/src/edlFormats.js b/src/edlFormats.js index 6796c99..1f9b2cf 100644 --- a/src/edlFormats.js +++ b/src/edlFormats.js @@ -295,3 +295,54 @@ export function parseDvAnalyzerSummaryTxt(txt) { return edl; } + +// http://www.textfiles.com/uploads/kds-srt.txt +export function parseSrt(text) { + const ret = []; + + // working state + let subtitleIndexAt; + let start; + let end; + let lines = []; + + const flush = () => { + if (start != null && end != null && lines.length > 0) { + ret.push({ start, end, name: lines.join('\r\n'), tags: { index: subtitleIndexAt } }); + } + start = undefined; + end = undefined; + subtitleIndexAt = undefined; + lines = []; + }; + + // eslint-disable-next-line no-restricted-syntax + for (const lineRaw of text.trim().split('\r\n')) { + const line = lineRaw.trim(); + if (line === '') { + flush(); + } else if (subtitleIndexAt != null && subtitleIndexAt > 0) { + const match = line.match(/^(\d+:\d+:\d+[,.]\d+\s+)-->(\s+\d+:\d+:\d+[,.]\d+)$/); + if (match) { + const fixComma = (v) => v.replace(/,/g, '.'); + start = parseTime(fixComma(match[1]))?.time; + end = parseTime(fixComma(match[2]))?.time; + } else if (start != null && end != null) { + lines.push(line); + } + } else if (/^\d+$/.test(line)) { + const parsedIndex = parseInt(line, 10); + if (!Number.isNaN(parsedIndex) && parsedIndex > 0) { + subtitleIndexAt = parsedIndex; + } + } + } + + flush(); + + return ret; +} + +export function formatSrt(segments) { + return segments.reduce((acc, segment, index) => `${acc}${index > 0 ? '\r\n' : ''}${index + 1}\r\n${formatDuration({ seconds: segment.start }).replace(/\./g, ',')} --> ${formatDuration({ seconds: segment.end }).replace(/\./g, ',')}\r\n${segment.name || '-'}\r\n`, ''); +} diff --git a/src/edlFormats.test.js b/src/edlFormats.test.js index de2f908..5f81d17 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, parseCsvTime, getFrameValParser, formatCsvFrames, getFrameCountRaw, parsePbf, parseDvAnalyzerSummaryTxt } from './edlFormats'; +import { parseSrt, formatSrt, 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)); @@ -248,6 +248,14 @@ it('parses pbf', async () => { expect(parsePbf(await readFixture('potplayer bookmark format utf16le issue 867.pbf', null))).toMatchSnapshot(); }); +it('parses srt', async () => { + expect(parseSrt(await readFixture('sample.srt'))).toMatchSnapshot(); +}); + +it('format srt', async () => { + expect(formatSrt(parseSrt(await readFixture('sample.srt')))).toMatchSnapshot(); +}); + // https://github.com/mifi/lossless-cut/issues/1664 it('parses DV Analyzer Summary.txt', async () => { expect(parseDvAnalyzerSummaryTxt(await readFixture('DV Analyzer Summary.txt', 'utf-8'))).toMatchSnapshot(); diff --git a/src/edlStore.js b/src/edlStore.js index 789d89c..7ac6ddd 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, parseCsvTime, getFrameValParser, parseDvAnalyzerSummaryTxt } from './edlFormats'; +import { parseSrt, formatSrt, parseCuesheet, parseXmeml, parseFcpXml, parseCsv, parsePbf, parseMplayerEdl, formatCsvHuman, formatTsv, formatCsvFrames, formatCsvSeconds, parseCsvTime, getFrameValParser, parseDvAnalyzerSummaryTxt } from './edlFormats'; import { askForYouTubeInput, showOpenDialog } from './dialogs'; import { getOutPath } from './util'; @@ -44,6 +44,10 @@ export async function loadCue(path) { return parseCuesheet(cueParser.parse(path)); } +export async function loadSrt(path) { + return parseSrt(await fs.readFile(path, 'utf-8')); +} + export async function saveCsv(path, cutSegments) { await fs.writeFile(path, await formatCsvSeconds(cutSegments)); } @@ -60,6 +64,11 @@ export async function saveTsv(path, cutSegments) { await fs.writeFile(path, await formatTsv(cutSegments)); } +export async function saveSrt(path, cutSegments) { + await fs.writeFile(path, await formatSrt(cutSegments)); +} + + export async function saveLlcProject({ savePath, filePath, cutSegments }) { const projectData = { version: 1, @@ -83,6 +92,7 @@ export async function readEdlFile({ type, path, fps }) { if (type === 'cue') return loadCue(path); if (type === 'pbf') return loadPbf(path); if (type === 'mplayer') return loadMplayerEdl(path); + if (type === 'srt') return loadSrt(path); if (type === 'llc') { const project = await loadLlcProject(path); return project.cutSegments; @@ -101,6 +111,7 @@ export async function askForEdlImport({ type, fps }) { 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 === 'dv-analyzer-summary-txt') filters = [{ name: i18n.t('DV Analyzer Summary.txt'), extensions: ['txt'] }]; + else if (type === 'srt') filters = [{ name: i18n.t('Subtitles (SRT)'), extensions: ['srt'] }]; else if (type === 'llc') filters = [{ name: i18n.t('LosslessCut project'), extensions: ['llc'] }]; const { canceled, filePaths } = await showOpenDialog({ properties: ['openFile'], filters }); @@ -123,6 +134,9 @@ export async function exportEdlFile({ type, cutSegments, customOutDir, filePath, } else if (type === 'csv-frames') { ext = 'csv'; filters = [{ name: i18n.t('TXT files'), extensions: [ext, 'txt'] }]; + } else if (type === 'srt') { + ext = 'srt'; + filters = [{ name: i18n.t('Subtitles (SRT)'), extensions: [ext, 'txt'] }]; } else if (type === 'llc') { ext = 'llc'; filters = [{ name: i18n.t('LosslessCut project'), extensions: [ext, 'llc'] }]; @@ -138,4 +152,5 @@ export async function exportEdlFile({ type, cutSegments, customOutDir, filePath, else if (type === 'csv-human') await saveCsvHuman(savePath, cutSegments); else if (type === 'csv-frames') await saveCsvFrames({ path: savePath, cutSegments, getFrameCount }); else if (type === 'llc') await saveLlcProject({ savePath, filePath, cutSegments }); + else if (type === 'srt') await saveSrt(savePath, cutSegments); } diff --git a/src/fixtures/sample.srt b/src/fixtures/sample.srt new file mode 100644 index 0000000..36c257b --- /dev/null +++ b/src/fixtures/sample.srt @@ -0,0 +1,8 @@ +1 +00:00:02,000 --> 00:00:06,000 +First subtitle + +2 +00:00:28,967 --> 01:30:30.95 +Subtitle 2 line 1 +Subtitle 2 line 2