CSV export/import tags as extra columns

closes #2507
pull/2514/head
Mikael Finstad 3 months ago
parent 2508079915
commit 250808703f
No known key found for this signature in database
GPG Key ID: 25AB36E3E81CBC26

@ -63,7 +63,7 @@ import {
} from './ffmpeg';
import { shouldCopyStreamByDefault, getAudioStreams, getRealVideoStreams, isAudioDefinitelyNotSupported, willPlayerProperlyHandleVideo, doesPlayerSupportHevcPlayback, getSubtitleStreams, enableVideoTrack, enableAudioTrack, canHtml5PlayerPlayStreams } from './util/streams';
import { exportEdlFile, readEdlFile, loadLlcProject, askForEdlImport } from './edlStore';
import { formatYouTube, getFrameCountRaw, formatTsv } from './edlFormats';
import { formatYouTube, getFrameCountRaw, formatTsvHuman } from './edlFormats';
import {
getOutPath, getSuffixedOutPath, handleError, getOutDir,
isStoreBuild, dragPreventer,
@ -1934,7 +1934,7 @@ function App() {
const copySegmentsToClipboard = useCallback(async () => {
if (!isFileOpened || selectedSegments.length === 0) return;
electron.clipboard.writeText(await formatTsv(selectedSegments));
electron.clipboard.writeText(formatTsvHuman(selectedSegments));
}, [isFileOpened, selectedSegments]);
const showIncludeExternalStreamsDialog = useCallback(async () => {

@ -205,9 +205,9 @@ const csvFramesStr = `\
688,747,EP106_SQ020_SH0010
`;
it('parses csv with frames', async () => {
it('parses csv with frames', () => {
const fps = 30;
const parsed = await parseCsv(csvFramesStr, getFrameValParser(fps));
const parsed = parseCsv(csvFramesStr, getFrameValParser(fps));
expect(parsed).toEqual([
{ end: 5.166666666666667, name: 'EP106_SQ010_SH0010', start: 0 },
@ -216,11 +216,11 @@ it('parses csv with frames', async () => {
{ end: 24.9, name: 'EP106_SQ020_SH0010', start: 22.933333333333334 },
]);
const formatted = await formatCsvFrames({
const formatted = formatCsvFrames({
cutSegments: parsed,
getFrameCount: (sec) => getFrameCountRaw(fps, sec),
});
expect(formatted).toEqual(csvFramesStr);
expect(formatted).toEqual(`${['Start', 'End', 'Name'].join(',')}\n${csvFramesStr}`);
});
const csvTimestampStr = `\
@ -232,8 +232,8 @@ const csvTimestampStr = `\
0:2,0:3,F
`;
it('parses csv with timestamps', async () => {
const parsed = await parseCsv(csvTimestampStr, parseCsvTime);
it('parses csv with timestamps', () => {
const parsed = parseCsv(csvTimestampStr, parseCsvTime);
expect(parsed).toEqual([
{ end: 189.053, name: 'A', start: 114.612 },
@ -245,8 +245,8 @@ it('parses csv with timestamps', async () => {
]);
});
it('parses csv with 2 only columns', async () => {
const parsed = await parseCsv('1,2\n3,4\n', parseCsvTime);
it('parses csv with 2 columns (no name)', () => {
const parsed = parseCsv('1,2\n3,4\n', parseCsvTime);
expect(parsed).toEqual([
{ start: 1, end: 2 },
@ -254,6 +254,41 @@ it('parses csv with 2 only columns', async () => {
]);
});
it('parses csv with 1 column (markers)', () => {
const parsed = parseCsv('1\n2\n3\n', parseCsvTime);
expect(parsed).toEqual([
{ start: 1 },
{ start: 2 },
{ start: 3 },
]);
});
const csvWithTagsHeader = 'Start,End,Name,group,Name';
const csvWithTags = `\
1,2,Name1,group1,This is a tag which is also called Name
3,4,Name2,,Name
5,6,Name3,"Group 2",`;
it('parses csv with tags', () => {
const csv = `${csvWithTagsHeader}\n${csvWithTags}\n`;
const parsed = parseCsv(csv, parseCsvTime);
expect(parsed).toEqual([
{ start: 1, end: 2, name: 'Name1', tags: { group: 'group1', Name: 'This is a tag which is also called Name' } },
{ start: 3, end: 4, name: 'Name2', tags: { Name: 'Name' } },
{ start: 5, end: 6, name: 'Name3', tags: { group: 'Group 2' } },
]);
});
it('parses csv with tags but no header', () => {
const parsed = parseCsv(csvWithTags, parseCsvTime);
expect(parsed).toEqual([
{ start: 1, end: 2, name: 'Name1', tags: { tag1: 'group1', tag2: 'This is a tag which is also called Name' } },
{ start: 3, end: 4, name: 'Name2', tags: { tag2: 'Name' } },
{ start: 5, end: 6, name: 'Name3', tags: { tag1: 'Group 2' } },
]);
});
const cutlistStr = `
[General]
Application=SomeApplication.exe

@ -14,6 +14,7 @@ import { invertSegments, sortSegments } from './segments';
import { GetFrameCount, SegmentBase, SegmentTags } from './types';
import parseCmx3600 from './cmx3600';
export const getTimeFromFrameNum = (detectedFps: number, frameNum: number) => frameNum / detectedFps;
export function getFrameCountRaw(detectedFps: number | undefined, sec: number) {
@ -47,16 +48,51 @@ export const getFrameValParser = (fps: number) => (str: string) => {
return getTimeFromFrameNum(fps, frameCount);
};
export async function parseCsv(csvStr: string, parseTimeFn: (a: string) => number | undefined) {
const rows = csvParse(csvStr, {}) as ([string, string] | [string, string, string])[];
const csvHeader = [
'Start',
'End',
'Name',
] as const;
export function parseCsv(csvStr: string, parseTimeFn: (a: string) => number | undefined) {
const rows: string[][] = csvParse(csvStr, {});
if (rows.length === 0) throw new Error(i18n.t('No rows found'));
if (!rows.every((row) => row.length >= 2 && row.length <= 3)) throw new Error(i18n.t('One or more rows does not have 3 columns'));
invariant(rows.every((row) => row.length > 0), 'One row had no columns.');
const mapped = rows.map(([start, end, name]) => ({
start: parseTimeFn(start) ?? 0,
end: parseTimeFn(end),
name: name?.trim(),
}));
// from header
let tagsKeys: string[] | undefined;
const mapped = rows.flatMap(([start, end, name, ...tagsColumns], rowIndex) => {
invariant(start != null, `Row ${rowIndex + 1} has no start time`);
if (rowIndex === 0
&& start === csvHeader[0]
&& (end == null || end === csvHeader[1])
&& (name == null || name === csvHeader[2])
) {
if (end === csvHeader[1] && name === csvHeader[2]) {
tagsKeys = tagsColumns.map((tag) => tag.trim());
}
// skip header row
return [];
}
return [{
start: parseTimeFn(start) ?? 0,
...(end != null && { end: parseTimeFn(end) }),
...(name != null && { name: name?.trim() }),
...(tagsColumns.length > 0 && {
tags: Object.fromEntries(tagsColumns.flatMap((tagValue, tagIndex) => {
if (tagValue.trim() === '') return [];
return [[
tagsKeys?.[tagIndex] ?? `tag${tagIndex + 1}`,
tagValue.trim(),
]];
})),
}),
}];
});
if (!mapped.every(({ start, end }) => (
!Number.isNaN(start)
@ -312,35 +348,43 @@ export function formatYouTube(segments: { start: number, name?: string }[]) {
// because null/undefined is also valid values (start/end of timeline)
const safeFormatDuration = (duration: number | undefined) => (duration != null ? formatDuration({ seconds: duration }) : '');
export const formatSegmentsTimes = (cutSegments: SegmentBase[]) => cutSegments.map(({ start, end, name }) => [
safeFormatDuration(start),
safeFormatDuration(end),
name,
]);
type Segment = SegmentBase & { tags?: SegmentTags | undefined };
const segmentToColumns = (segments: Segment[], formatTime: (t: number | undefined) => string) => {
const tagsColumnNames = sortBy([...new Set(segments.flatMap((segment) => (segment.tags != null ? Object.keys(segment.tags) : [])))]);
const header = [
...csvHeader,
...tagsColumnNames,
];
return [
header,
...segments.map(({ start, end, name, tags }) => [
formatTime(start),
formatTime(end),
name ?? '',
...tagsColumnNames.map((key) => tags?.[key] ?? ''),
]),
];
};
export async function formatCsvFrames({ cutSegments, getFrameCount }: { cutSegments: SegmentBase[], getFrameCount: GetFrameCount }) {
const safeFormatFrameCount = (seconds: number | undefined) => (seconds != null ? getFrameCount(seconds) : '');
const formatted = cutSegments.map(({ start, end, name }) => [
safeFormatFrameCount(start),
safeFormatFrameCount(end),
name,
]);
export function formatCsvFrames({ cutSegments, getFrameCount }: { cutSegments: Segment[], getFrameCount: GetFrameCount }) {
const safeFormatFrameCount = (seconds: number | undefined) => String((seconds != null ? getFrameCount(seconds) : undefined) ?? '');
return csvStringify(formatted);
return csvStringify(segmentToColumns(cutSegments, safeFormatFrameCount));
}
export async function formatCsvSeconds(cutSegments: SegmentBase[]) {
const rows = cutSegments.map(({ start, end, name }) => [start, end, name]);
return csvStringify(rows);
export function formatCsvSeconds(cutSegments: Segment[]) {
return csvStringify(segmentToColumns(cutSegments, String));
}
export async function formatCsvHuman(cutSegments: SegmentBase[]) {
return csvStringify(formatSegmentsTimes(cutSegments));
export function formatCsvHuman(cutSegments: Segment[]) {
return csvStringify(segmentToColumns(cutSegments, safeFormatDuration));
}
export async function formatTsv(cutSegments: SegmentBase[]) {
return csvStringify(formatSegmentsTimes(cutSegments), { delimiter: '\t' });
export function formatTsvHuman(cutSegments: Segment[]) {
return csvStringify(segmentToColumns(cutSegments, safeFormatDuration), { delimiter: '\t' });
}
export function parseDvAnalyzerSummaryTxt(txt: string) {

@ -3,7 +3,7 @@ import i18n from 'i18next';
import invariant from 'tiny-invariant';
import { ZodError } from 'zod';
import { parseSrtToSegments, formatSrt, parseCuesheet, parseXmeml, parseFcpXml, parseCsv, parseCutlist, parsePbf, parseEdl, formatCsvHuman, formatTsv, formatCsvFrames, formatCsvSeconds, parseCsvTime, getFrameValParser, parseDvAnalyzerSummaryTxt, parseOtio } from './edlFormats';
import { parseSrtToSegments, formatSrt, parseCuesheet, parseXmeml, parseFcpXml, parseCsv, parseCutlist, parsePbf, parseEdl, formatCsvHuman, formatTsvHuman, formatCsvFrames, formatCsvSeconds, parseCsvTime, getFrameValParser, parseDvAnalyzerSummaryTxt, parseOtio } from './edlFormats';
import { askForYouTubeInput, showOpenDialog } from './dialogs';
import { getOutPath } from './util';
import { EdlExportType, EdlFileType, EdlImportType, GetFrameCount, LlcProject, llcProjectV1Schema, llcProjectV2Schema, SegmentBase, StateSegment } from './types';
@ -17,7 +17,7 @@ const { basename } = window.require('path');
const { dialog } = window.require('@electron/remote');
async function loadCsvSeconds(path: string) {
async function loadCsv(path: string) {
return parseCsv(await readFile(path, 'utf8'), parseCsvTime);
}
@ -58,11 +58,11 @@ async function loadSrt(path: string) {
}
export async function saveCsv(path: string, cutSegments: SegmentBase[]) {
await writeFile(path, await formatCsvSeconds(cutSegments));
await writeFile(path, formatCsvSeconds(cutSegments));
}
export async function saveCsvHuman(path: string, cutSegments: SegmentBase[]) {
await writeFile(path, await formatCsvHuman(cutSegments));
await writeFile(path, formatCsvHuman(cutSegments));
}
export async function saveCsvFrames({ path, cutSegments, getFrameCount }: {
@ -74,7 +74,7 @@ export async function saveCsvFrames({ path, cutSegments, getFrameCount }: {
}
export async function saveTsv(path: string, cutSegments: SegmentBase[]) {
await writeFile(path, await formatTsv(cutSegments));
await writeFile(path, formatTsvHuman(cutSegments));
}
export async function saveSrt(path: string, cutSegments: SegmentBase[]) {
@ -134,7 +134,7 @@ export async function readEdlFile({ type, path, fps }: {
path: string,
fps: number | undefined,
}) {
if (type === 'csv') return loadCsvSeconds(path);
if (type === 'csv') return loadCsv(path);
if (type === 'csv-frames' || type === 'edl') {
invariant(fps != null, 'The loaded media has an unknown framerate');
if (type === 'csv-frames') return loadCsvFrames(path, fps);

Loading…
Cancel
Save