mirror of https://github.com/mifi/lossless-cut
Many improvements
- Add config option for asking about file open #467 - Implement text/youtube segments import #458 - Implement CUE sheet import #458 - Implement XMEML import (Final Cut Pro / Davinci Resolve) - Allow import embedded chapters as segments #300pull/471/head
parent
6546f080d2
commit
81eb66faa2
@ -0,0 +1,96 @@
|
|||||||
|
import fastXmlParser from 'fast-xml-parser';
|
||||||
|
import i18n from 'i18next';
|
||||||
|
|
||||||
|
import csvParse from 'csv-parse';
|
||||||
|
import pify from 'pify';
|
||||||
|
import sortBy from 'lodash/sortBy';
|
||||||
|
|
||||||
|
const csvParseAsync = pify(csvParse);
|
||||||
|
|
||||||
|
export async function parseCsv(str) {
|
||||||
|
const rows = await csvParseAsync(str, {});
|
||||||
|
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'));
|
||||||
|
|
||||||
|
const mapped = rows
|
||||||
|
.map(([start, end, name]) => ({
|
||||||
|
start: start === '' ? undefined : parseFloat(start, 10),
|
||||||
|
end: end === '' ? undefined : parseFloat(end, 10),
|
||||||
|
name,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!mapped.every(({ start, end }) => (
|
||||||
|
(start === undefined || !Number.isNaN(start))
|
||||||
|
&& (end === undefined || !Number.isNaN(end))
|
||||||
|
))) {
|
||||||
|
console.log(mapped);
|
||||||
|
throw new Error(i18n.t('Invalid start or end value. Must contain a number of seconds'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCuesheet(cuesheet) {
|
||||||
|
// There are 75 such frames per second of audio.
|
||||||
|
// https://en.wikipedia.org/wiki/Cue_sheet_(computing)
|
||||||
|
const fps = 75;
|
||||||
|
|
||||||
|
const { tracks } = cuesheet.files[0];
|
||||||
|
|
||||||
|
function parseTime(track) {
|
||||||
|
const index = track.indexes[0];
|
||||||
|
if (!index) return undefined;
|
||||||
|
const { time } = index;
|
||||||
|
if (!time) return undefined;
|
||||||
|
|
||||||
|
return (time.min * 60) + time.sec + time.frame / fps;
|
||||||
|
}
|
||||||
|
|
||||||
|
return tracks.map((track, i) => {
|
||||||
|
const nextTrack = tracks[i + 1];
|
||||||
|
const end = nextTrack && parseTime(nextTrack);
|
||||||
|
|
||||||
|
return { name: track.title, start: parseTime(track), end };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/FinalCutPro_XML/VersionsoftheInterchangeFormat/VersionsoftheInterchangeFormat.html
|
||||||
|
export function parseXmeml(xmlStr) {
|
||||||
|
const xml = fastXmlParser.parse(xmlStr);
|
||||||
|
// TODO maybe support media.audio also?
|
||||||
|
return xml.xmeml.project.children.sequence.media.video.track.clipitem.map((item) => ({ start: item.start / item.rate.timebase, end: item.end / item.rate.timebase }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseYouTube(str) {
|
||||||
|
const regex = /(?:([0-9]{2,}):)?([0-9]{2}):([0-9]{2})(?:\.([0-9]{3}))?[^\S\n]+([^\n]*)\n/g;
|
||||||
|
|
||||||
|
const lines = [];
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
return { time, name };
|
||||||
|
}
|
||||||
|
|
||||||
|
let m;
|
||||||
|
// eslint-disable-next-line no-cond-assign
|
||||||
|
while ((m = regex.exec(`${str}\n`))) {
|
||||||
|
lines.push(parseLine(m));
|
||||||
|
}
|
||||||
|
|
||||||
|
const linesSorted = sortBy(lines, (l) => l.time);
|
||||||
|
|
||||||
|
const edl = linesSorted.map((line, i) => {
|
||||||
|
const nextLine = linesSorted[i + 1];
|
||||||
|
return { start: line.time, end: nextLine && nextLine.time, name: line.name };
|
||||||
|
});
|
||||||
|
|
||||||
|
return edl.filter((ed) => ed.start !== ed.end);
|
||||||
|
}
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
/* eslint-disable no-undef */
|
||||||
|
import { parseYouTube } from './edlFormats';
|
||||||
|
|
||||||
|
it('parseYoutube', () => {
|
||||||
|
const str = `
|
||||||
|
Jump to chapters:
|
||||||
|
00:00 Test 1
|
||||||
|
00:01 Test 2
|
||||||
|
00:02 00:57 double
|
||||||
|
00:01:01 Test 3
|
||||||
|
01:01:01.012 Test 4
|
||||||
|
00:01:01.012 Test 5
|
||||||
|
01:01.012 Test 6
|
||||||
|
:01:02.012 Test 7
|
||||||
|
00:57:01.0123 Invalid 2
|
||||||
|
00:57:01. Invalid 3
|
||||||
|
01:15: Invalid 4
|
||||||
|
0132 Invalid 5
|
||||||
|
00:03
|
||||||
|
00:04
|
||||||
|
00:05
|
||||||
|
`;
|
||||||
|
const edl = parseYouTube(str);
|
||||||
|
expect(edl).toEqual([
|
||||||
|
{ start: 0, end: 1, name: 'Test 1' },
|
||||||
|
{ 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: 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: 3661.012, end: undefined, name: 'Test 4' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parseYouTube eol', () => {
|
||||||
|
const str = ' 00:00 Test 1\n00:01 Test 2';
|
||||||
|
const edl = parseYouTube(str);
|
||||||
|
expect(edl).toEqual([
|
||||||
|
{ start: 0, end: 1, name: 'Test 1' },
|
||||||
|
{ start: 1, end: undefined, name: 'Test 2' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
@ -1,48 +1,28 @@
|
|||||||
import parse from 'csv-parse';
|
import csvStringify from 'csv-stringify';
|
||||||
import stringify from 'csv-stringify';
|
import pify from 'pify';
|
||||||
import i18n from 'i18next';
|
|
||||||
import fastXmlParser from 'fast-xml-parser';
|
|
||||||
|
|
||||||
const fs = window.require('fs-extra');
|
import { parseCuesheet, parseXmeml, parseCsv } from './edlFormats';
|
||||||
const { promisify } = window.require('util');
|
|
||||||
|
|
||||||
const stringifyAsync = promisify(stringify);
|
|
||||||
const parseAsync = promisify(parse);
|
|
||||||
|
|
||||||
export async function load(path) {
|
|
||||||
const str = await fs.readFile(path, 'utf-8');
|
|
||||||
const rows = await parseAsync(str, {});
|
|
||||||
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'));
|
|
||||||
|
|
||||||
const mapped = rows
|
const fs = window.require('fs-extra');
|
||||||
.map(([start, end, name]) => ({
|
const cueParser = window.require('cue-parser');
|
||||||
start: start === '' ? undefined : parseFloat(start, 10),
|
|
||||||
end: end === '' ? undefined : parseFloat(end, 10),
|
|
||||||
name,
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (!mapped.every(({ start, end }) => (
|
const csvStringifyAsync = pify(csvStringify);
|
||||||
(start === undefined || !Number.isNaN(start))
|
|
||||||
&& (end === undefined || !Number.isNaN(end))
|
|
||||||
))) {
|
|
||||||
console.log(mapped);
|
|
||||||
throw new Error(i18n.t('Invalid start or end value. Must contain a number of seconds'));
|
|
||||||
}
|
|
||||||
|
|
||||||
return mapped;
|
export async function loadCsv(path) {
|
||||||
|
return parseCsv(await fs.readFile(path, 'utf-8'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/FinalCutPro_XML/VersionsoftheInterchangeFormat/VersionsoftheInterchangeFormat.html
|
|
||||||
export async function loadXmeml(path) {
|
export async function loadXmeml(path) {
|
||||||
const xml = fastXmlParser.parse(await fs.readFile(path, 'utf-8'));
|
return parseXmeml(await fs.readFile(path, 'utf-8'));
|
||||||
// TODO maybe support media.audio also?
|
}
|
||||||
return xml.xmeml.project.children.sequence.media.video.track.clipitem.map((item) => ({ start: item.start / item.rate.timebase, end: item.end / item.rate.timebase }));
|
|
||||||
|
export async function loadCue(path) {
|
||||||
|
return parseCuesheet(cueParser.parse(path));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function save(path, cutSegments) {
|
export async function saveCsv(path, cutSegments) {
|
||||||
console.log('Saving', path);
|
console.log('Saving', path);
|
||||||
const rows = cutSegments.map(({ start, end, name }) => [start, end, name]);
|
const rows = cutSegments.map(({ start, end, name }) => [start, end, name]);
|
||||||
const str = await stringifyAsync(rows);
|
const str = await csvStringifyAsync(rows);
|
||||||
await fs.writeFile(path, str);
|
await fs.writeFile(path, str);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue