diff --git a/src/main/menu.ts b/src/main/menu.ts index ca76641c..2b0492ed 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -91,9 +91,9 @@ export default ({ app, mainWindow, newVersion, isStoreBuild }: { }, }, { - label: esc(t('EDL (MPlayer)')), + label: esc(t('EDL')), click() { - mainWindow.webContents.send('importEdlFile', 'mplayer'); + mainWindow.webContents.send('importEdlFile', 'edl'); }, }, { diff --git a/src/renderer/src/cmx3600.ts b/src/renderer/src/cmx3600.ts new file mode 100644 index 00000000..b1fbdf70 --- /dev/null +++ b/src/renderer/src/cmx3600.ts @@ -0,0 +1,35 @@ +export interface EDLEvent { + eventNumber: string; + reelNumber: string; + trackType: string; + transition: string; + sourceIn: string; + sourceOut: string; + recordIn: string; + recordOut: string; +} + +export default function parseCmx3600(edlContent: string) { + const lines = edlContent.split('\n'); + const events: EDLEvent[] = []; + + for (const line of lines) { + if (/^\d+\s+/.test(line)) { + const parts = line.trim().split(/\s+/); + if (parts.length >= 8) { + events.push({ + eventNumber: parts[0]!, + reelNumber: parts[1]!, + trackType: parts[2]!, + transition: parts[3]!, + sourceIn: parts[4]!, + sourceOut: parts[5]!, + recordIn: parts[6]!, + recordOut: parts[7]!, + }); + } + } + } + + return { events }; +} diff --git a/src/renderer/src/edlFormats.ts b/src/renderer/src/edlFormats.ts index 4f11ee21..dcfcb6b1 100644 --- a/src/renderer/src/edlFormats.ts +++ b/src/renderer/src/edlFormats.ts @@ -1,6 +1,7 @@ import { XMLParser } from 'fast-xml-parser'; import i18n from 'i18next'; import invariant from 'tiny-invariant'; +import { Duration } from 'luxon'; import { parse as csvParse } from 'csv-parse/browser/esm/sync'; import { stringify as csvStringify } from 'csv-stringify/browser/esm/sync'; @@ -10,6 +11,7 @@ import type { ICueSheet, ITrack } from 'cue-parser/lib/types'; import { formatDuration } from './util/duration'; import { invertSegments, sortSegments } from './segments'; import { GetFrameCount, Segment, SegmentBase } from './types'; +import parseCmx3600 from './cmx3600'; export const getTimeFromFrameNum = (detectedFps: number, frameNum: number) => frameNum / detectedFps; @@ -160,6 +162,32 @@ export async function parseMplayerEdl(text: string) { return out; } +export async function parseEdlCmx3600(text: string, fps: number) { + const cmx = parseCmx3600(text); + + const parseTimecode = (t: string) => { + const match = t.match(/^(\d+)[:;](\d+)[:;](\d+)[:;](\d+)$/); + invariant(match, `Invalid EDL line: ${t}`); + const hours = parseInt(match[1]!, 10); + const minutes = parseInt(match[2]!, 10); + const seconds = parseInt(match[3]!, 10); + const frames = parseInt(match[4]!, 10); + return Duration.fromObject({ hours, minutes, seconds: seconds + (frames / fps) }).as('seconds'); + }; + + return cmx.events.map((event) => ({ + start: parseTimecode(event.sourceIn), + end: parseTimecode(event.sourceOut), + name: event.eventNumber, + tags: { reel: event.reelNumber, trackType: event.trackType, transition: event.transition }, + })); +} + +export async function parseEdl(text: string, fps: number) { + if (text.startsWith('TITLE: ')) return parseEdlCmx3600(text, fps); + return parseMplayerEdl(text); +} + export function parseCuesheet(cuesheet: ICueSheet) { // There are 75 such frames per second of audio. // https://en.wikipedia.org/wiki/Cue_sheet_(computing) diff --git a/src/renderer/src/edlStore.ts b/src/renderer/src/edlStore.ts index c3dffe9f..a02e6b7f 100644 --- a/src/renderer/src/edlStore.ts +++ b/src/renderer/src/edlStore.ts @@ -2,10 +2,10 @@ import JSON5 from 'json5'; import i18n from 'i18next'; import invariant from 'tiny-invariant'; -import { parseSrtToSegments, formatSrt, parseCuesheet, parseXmeml, parseFcpXml, parseCsv, parseCutlist, parsePbf, parseMplayerEdl, formatCsvHuman, formatTsv, formatCsvFrames, formatCsvSeconds, parseCsvTime, getFrameValParser, parseDvAnalyzerSummaryTxt } from './edlFormats'; +import { parseSrtToSegments, formatSrt, parseCuesheet, parseXmeml, parseFcpXml, parseCsv, parseCutlist, parsePbf, parseEdl, formatCsvHuman, formatTsv, formatCsvFrames, formatCsvSeconds, parseCsvTime, getFrameValParser, parseDvAnalyzerSummaryTxt } from './edlFormats'; import { askForYouTubeInput, showOpenDialog } from './dialogs'; import { getOutPath } from './util'; -import { EdlExportType, EdlFileType, EdlImportType, Segment, StateSegment } from './types'; +import { EdlExportType, EdlFileType, EdlImportType, GetFrameCount, Segment, StateSegment } from './types'; const { readFile, writeFile } = window.require('fs/promises'); const cueParser = window.require('cue-parser'); @@ -13,68 +13,76 @@ const { basename } = window.require('path'); const { dialog } = window.require('@electron/remote'); -export async function loadCsvSeconds(path: string) { + +async function loadCsvSeconds(path: string) { return parseCsv(await readFile(path, 'utf8'), parseCsvTime); } -export async function loadCsvFrames(path: string, fps?: number) { - if (!fps) throw new Error('The loaded file has an unknown framerate'); +async function loadCsvFrames(path: string, fps: number) { return parseCsv(await readFile(path, 'utf8'), getFrameValParser(fps)); } -export async function loadCutlistSeconds(path: string) { +async function loadCutlistSeconds(path: string) { return parseCutlist(await readFile(path, 'utf8')); } -export async function loadXmeml(path: string) { +async function loadXmeml(path: string) { return parseXmeml(await readFile(path, 'utf8')); } -export async function loadFcpXml(path: string) { +async function loadFcpXml(path: string) { return parseFcpXml(await readFile(path, 'utf8')); } -export async function loadDvAnalyzerSummaryTxt(path: string) { +async function loadDvAnalyzerSummaryTxt(path: string) { return parseDvAnalyzerSummaryTxt(await readFile(path, 'utf8')); } -export async function loadPbf(path: string) { +async function loadPbf(path: string) { return parsePbf(await readFile(path)); } -export async function loadMplayerEdl(path: string) { - return parseMplayerEdl(await readFile(path, 'utf8')); +async function loadEdl(path: string, fps: number) { + return parseEdl(await readFile(path, 'utf8'), fps); } -export async function loadCue(path: string) { +async function loadCue(path: string) { return parseCuesheet(cueParser.parse(path)); } -export async function loadSrt(path: string) { +async function loadSrt(path: string) { return parseSrtToSegments(await readFile(path, 'utf8')); } -export async function saveCsv(path: string, cutSegments) { +export async function saveCsv(path: string, cutSegments: Segment[]) { await writeFile(path, await formatCsvSeconds(cutSegments)); } -export async function saveCsvHuman(path: string, cutSegments) { +export async function saveCsvHuman(path: string, cutSegments: Segment[]) { await writeFile(path, await formatCsvHuman(cutSegments)); } -export async function saveCsvFrames({ path, cutSegments, getFrameCount }) { +export async function saveCsvFrames({ path, cutSegments, getFrameCount }: { + path: string, + cutSegments: Segment[], + getFrameCount: GetFrameCount, +}) { await writeFile(path, await formatCsvFrames({ cutSegments, getFrameCount })); } -export async function saveTsv(path: string, cutSegments) { +export async function saveTsv(path: string, cutSegments: Segment[]) { await writeFile(path, await formatTsv(cutSegments)); } -export async function saveSrt(path: string, cutSegments) { - await writeFile(path, await formatSrt(cutSegments)); +export async function saveSrt(path: string, cutSegments: Segment[]) { + await writeFile(path, formatSrt(cutSegments)); } -export async function saveLlcProject({ savePath, filePath, cutSegments }) { +export async function saveLlcProject({ savePath, filePath, cutSegments }: { + savePath: string, + filePath: string, + cutSegments: StateSegment[], +}) { const projectData = { version: 1, mediaFileName: basename(filePath), @@ -99,14 +107,20 @@ export async function loadLlcProject(path: string) { export async function readEdlFile({ type, path, fps }: { type: EdlFileType, path: string, fps?: number | undefined }) { if (type === 'csv') return loadCsvSeconds(path); - if (type === 'csv-frames') return loadCsvFrames(path, fps); + if (type === 'csv-frames') { + invariant(fps != null, 'The loaded media has an unknown framerate'); + return loadCsvFrames(path, fps); + } if (type === 'cutlist') return loadCutlistSeconds(path); if (type === 'xmeml') return loadXmeml(path); if (type === 'fcpxml') return loadFcpXml(path); if (type === 'dv-analyzer-summary-txt') return loadDvAnalyzerSummaryTxt(path); if (type === 'cue') return loadCue(path); if (type === 'pbf') return loadPbf(path); - if (type === 'mplayer') return loadMplayerEdl(path); + if (type === 'edl') { + invariant(fps != null, 'The loaded media has an unknown framerate'); + return loadEdl(path, fps); + } if (type === 'srt') return loadSrt(path); if (type === 'llc') { const project = await loadLlcProject(path); @@ -125,7 +139,7 @@ export async function askForEdlImport({ type, fps }: { type: EdlImportType, fps? else if (type === 'fcpxml') filters = [{ name: i18n.t('FCPXML files'), extensions: ['fcpxml'] }]; 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 === 'edl') filters = [{ name: i18n.t('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'] }]; @@ -137,7 +151,11 @@ export async function askForEdlImport({ type, fps }: { type: EdlImportType, fps? } export async function exportEdlFile({ type, cutSegments, customOutDir, filePath, getFrameCount }: { - type: EdlExportType, cutSegments: Segment[], customOutDir?: string | undefined, filePath?: string | undefined, getFrameCount: (a: number) => number | undefined, + type: EdlExportType, + cutSegments: StateSegment[], + customOutDir?: string | undefined, + filePath?: string | undefined, + getFrameCount: GetFrameCount, }) { invariant(filePath != null); diff --git a/src/renderer/src/hooks/useSegmentsAutoSave.ts b/src/renderer/src/hooks/useSegmentsAutoSave.ts index d356629b..4244cc7b 100644 --- a/src/renderer/src/hooks/useSegmentsAutoSave.ts +++ b/src/renderer/src/hooks/useSegmentsAutoSave.ts @@ -50,6 +50,10 @@ export default ({ autoSaveProjectFile, storeProjectInWorkingDir, filePath, custo return; } + if (debouncedSaveOperation.filePath == null) { + return; + } + await saveLlcProject({ savePath: debouncedSaveOperation.projectFileSavePath, filePath: debouncedSaveOperation.filePath, cutSegments: debouncedSaveOperation.cutSegments }); lastSaveOperation.current = debouncedSaveOperation; } catch (err) { diff --git a/src/renderer/src/types.ts b/src/renderer/src/types.ts index 12158318..012c210f 100644 --- a/src/renderer/src/types.ts +++ b/src/renderer/src/types.ts @@ -72,7 +72,7 @@ export interface InverseCutSegment { export type PlaybackMode = 'loop-segment-start-end' | 'loop-segment' | 'play-segment-once' | 'loop-selected-segments'; -export type EdlFileType = 'csv' | 'csv-frames' | 'cutlist' | 'xmeml' | 'fcpxml' | 'dv-analyzer-summary-txt' | 'cue' | 'pbf' | 'mplayer' | 'srt' | 'llc'; +export type EdlFileType = 'csv' | 'csv-frames' | 'cutlist' | 'xmeml' | 'fcpxml' | 'dv-analyzer-summary-txt' | 'cue' | 'pbf' | 'edl' | 'srt' | 'llc'; export type EdlImportType = 'youtube' | EdlFileType; diff --git a/src/renderer/src/util.ts b/src/renderer/src/util.ts index 0ce55f65..89c49657 100644 --- a/src/renderer/src/util.ts +++ b/src/renderer/src/util.ts @@ -506,7 +506,7 @@ export async function readDirRecursively(dirPath: string) { export function getImportProjectType(filePath: string) { if (filePath.endsWith('Summary.txt')) return 'dv-analyzer-summary-txt'; - const edlFormatForExtension = { csv: 'csv', pbf: 'pbf', edl: 'mplayer', cue: 'cue', xml: 'xmeml', fcpxml: 'fcpxml' }; + const edlFormatForExtension = { csv: 'csv', pbf: 'pbf', edl: 'edl', cue: 'cue', xml: 'xmeml', fcpxml: 'fcpxml' }; const matchingExt = Object.keys(edlFormatForExtension).find((ext) => filePath.toLowerCase().endsWith(`.${ext}`)); if (!matchingExt) return undefined; return edlFormatForExtension[matchingExt];