Skip to content

Commit d4d14ab

Browse files
authored
Highlighter/export markers (#5478)
* added export markers modal * added markers export for davinci resolve * added csv export for markers * added markers export as youtube chapter metadata * added translation strings * fixed failing tests
1 parent b82d46c commit d4d14ab

File tree

8 files changed

+475
-4
lines changed

8 files changed

+475
-4
lines changed

app/components-react/highlighter/ClipsView.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import MiniClipPreview from './MiniClipPreview';
2828
import HighlightGenerator from './HighlightGenerator';
2929
import { EAvailableFeatures } from 'services/incremental-rollout';
3030

31-
export type TModalClipsView = 'trim' | 'export' | 'preview' | 'remove';
31+
export type TModalClipsView = 'trim' | 'export' | 'preview' | 'remove' | 'exportMarkers';
3232

3333
interface IClipsViewProps {
3434
id: string | undefined;
@@ -468,10 +468,25 @@ function PreviewExportButton({
468468
const clips = useVuex(() =>
469469
HighlighterService.getClips(HighlighterService.views.clips, streamId),
470470
);
471+
const stream = useVuex(() =>
472+
streamId ? HighlighterService.views.highlightedStreamsDictionary[streamId] : null,
473+
);
471474
const hasClipsToExport = clips.some(clip => clip.enabled);
475+
const hasHighlights: boolean =
476+
(stream && stream.highlights && stream.highlights.length > 0) ?? false;
472477

473478
return (
474479
<>
480+
{hasHighlights && (
481+
<Tooltip
482+
title={$t('Export detectected timecodes as markers for editing software')}
483+
placement="bottom"
484+
>
485+
<Button disabled={!hasHighlights} onClick={() => setModal({ modal: 'exportMarkers' })}>
486+
{$t('Export Markers')}
487+
</Button>
488+
</Tooltip>
489+
)}
475490
<Tooltip
476491
title={!hasClipsToExport ? $t('Select at least one clip to preview your video') : null}
477492
placement="bottom"

app/components-react/highlighter/ClipsViewModal.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import ExportModal from 'components-react/highlighter/Export/ExportModal';
1010
import { $t } from 'services/i18n';
1111
import PreviewModal from './PreviewModal';
1212
import RemoveModal from './RemoveModal';
13+
import ExportMarkersModal from './ExportMarkersModal';
1314

1415
export default function ClipsViewModal({
1516
streamId,
@@ -46,12 +47,13 @@ export default function ClipsViewModal({
4647

4748
if (modal) {
4849
setModalWidth(
49-
{
50+
({
5051
trim: '60%',
5152
preview: '700px',
5253
export: 'fit-content',
5354
remove: '280px',
54-
}[modal],
55+
exportMarkers: 'fit-content',
56+
} as Record<string, string>)[modal] ?? '700px',
5557
);
5658
}
5759
}
@@ -91,6 +93,9 @@ export default function ClipsViewModal({
9193
}}
9294
/>
9395
)}
96+
{showModal === 'exportMarkers' && streamId && (
97+
<ExportMarkersModal close={closeModal} streamId={streamId} />
98+
)}
9499
{inspectedClip && showModal === 'remove' && (
95100
<RemoveModal
96101
key={`remove-${inspectedClip.path}`}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import React, { useEffect, useRef, useState } from 'react';
2+
import { Services } from 'components-react/service-provider';
3+
import { $t } from 'services/i18n';
4+
import { useVuex } from 'components-react/hooks';
5+
import { Button, Select, Checkbox } from 'antd';
6+
import { dialog } from '@electron/remote';
7+
import {
8+
exportCSV,
9+
exportEDL,
10+
exportYouTubeChapters,
11+
} from 'services/highlighter/markers-exporters';
12+
import { promises as fs } from 'fs';
13+
const { Option } = Select;
14+
15+
export default function ExportMarkersModal({
16+
close,
17+
streamId,
18+
}: {
19+
close: () => void;
20+
streamId: string;
21+
}) {
22+
const { HighlighterService } = Services;
23+
const stream = useVuex(() => HighlighterService.views.highlightedStreamsDictionary[streamId]);
24+
25+
const [markersFormat, setMarkersFormat] = useState('edl');
26+
// many professional video editors require the timeline to start from 01:00:00:00, so we default to true
27+
const [startFromHour, setStartFromHour] = useState(true);
28+
const [exportRanges, setExportRanges] = useState(false);
29+
const [exporting, setExporting] = useState(false);
30+
31+
const availableMarkersFormats = [
32+
{ value: 'edl', label: $t('DaVinci Resolve (EDL)') },
33+
{ value: 'csv', label: $t('CSV') },
34+
{ value: 'youtube', label: $t('YouTube Chapter Markers (for recording)') },
35+
];
36+
37+
const exportMarkers = async () => {
38+
if (stream.highlights?.length === 0) {
39+
return;
40+
}
41+
setExporting(true);
42+
43+
try {
44+
const { filePath, canceled } = await dialog.showSaveDialog({
45+
title: $t('Export Markers'),
46+
defaultPath: `${stream.title} - Markers.${
47+
markersFormat === 'youtube' ? 'txt' : markersFormat
48+
}`,
49+
});
50+
if (canceled || !filePath) {
51+
return;
52+
}
53+
54+
let content = '';
55+
if (markersFormat === 'csv') {
56+
content = await exportCSV(stream, exportRanges, startFromHour);
57+
} else if (markersFormat === 'edl') {
58+
content = await exportEDL(stream, exportRanges, startFromHour);
59+
} else if (markersFormat === 'youtube') {
60+
content = await exportYouTubeChapters(stream);
61+
}
62+
63+
await fs.writeFile(filePath, content, 'utf-8');
64+
close();
65+
} finally {
66+
setExporting(false);
67+
}
68+
};
69+
70+
return (
71+
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', width: '100%' }}>
72+
<h2>{$t('Export Markers')}</h2>
73+
<p>Select the video editing software you want to export markers for.</p>
74+
<Select
75+
style={{ width: '100%' }}
76+
value={markersFormat}
77+
onChange={value => setMarkersFormat(value)}
78+
>
79+
{availableMarkersFormats.map(option => (
80+
<Option key={option.value} value={option.value}>
81+
{option.label}
82+
</Option>
83+
))}
84+
</Select>
85+
{markersFormat !== 'youtube' && (
86+
<Checkbox
87+
checked={startFromHour}
88+
onChange={e => setStartFromHour(e.target.checked)}
89+
style={{ marginTop: '10px' }}
90+
>
91+
{$t('Timeline starts from 01:00:00 (default)')}
92+
</Checkbox>
93+
)}
94+
95+
{markersFormat !== 'youtube' && (
96+
<Checkbox
97+
checked={exportRanges}
98+
onChange={e => setExportRanges(e.target.checked)}
99+
style={{ marginTop: '6px', marginLeft: '0px' }}
100+
>
101+
{$t('Export full highlight duration as marker range')}
102+
</Checkbox>
103+
)}
104+
105+
<Button
106+
type="primary"
107+
style={{ marginTop: '20px', width: '100%' }}
108+
loading={exporting}
109+
onClick={exportMarkers}
110+
>
111+
{$t('Export')}
112+
</Button>
113+
</div>
114+
);
115+
}

app/i18n/en-US/highlighter.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,5 +205,9 @@
205205
"Auto-generate game highlight reels of your stream": "Auto-generate game highlight reels of your stream",
206206
"Get stream highlights!": "Get stream highlights!",
207207
"Turn your gameplay into epic highlight reels": "Turn your gameplay into epic highlight reels",
208-
"Dominate, showcase, inspire!": "Dominate, showcase, inspire!"
208+
"Dominate, showcase, inspire!": "Dominate, showcase, inspire!",
209+
"YouTube Chapter Markers (for recording)": "YouTube Chapter Markers (for recording)",
210+
"Export Markers": "Export Markers",
211+
"Timeline starts from 01:00:00 (default)": "Timeline starts from 01:00:00 (default)",
212+
"Export full highlight duration as marker range": "Export full highlight duration as marker range"
209213
}

app/services/highlighter/cut-highlight-clips.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,32 @@ export async function getVideoResolution(filePath: string): Promise<IResolution>
3737
return { width, height };
3838
}
3939

40+
export async function getVideoFramerateAndFrameCount(
41+
filePath: string,
42+
): Promise<{ framerate: number; totalFrames: number }> {
43+
const { stdout } = await execa(FFPROBE_EXE, [
44+
'-v',
45+
'error',
46+
'-select_streams',
47+
'v:0',
48+
'-show_entries',
49+
'stream=r_frame_rate,nb_frames',
50+
'-of',
51+
'default=noprint_wrappers=1:nokey=1',
52+
filePath,
53+
]);
54+
55+
const lines = stdout.trim().split('\n');
56+
const rateLine = lines[0]; // r_frame_rate
57+
const framesLine = lines[1]; // nb_frames
58+
59+
const [num, denom] = rateLine.split('/').map(Number);
60+
const framerate = denom ? num / denom : parseFloat(rateLine);
61+
const totalFrames = parseInt(framesLine, 10);
62+
63+
return { framerate, totalFrames };
64+
}
65+
4066
export async function cutHighlightClips(
4167
videoUri: string,
4268
highlighterData: IHighlight[],

app/services/highlighter/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1433,6 +1433,7 @@ export class HighlighterService extends PersistentStatefulService<IHighlighterSt
14331433
// 6. add highlight clips
14341434
progressTracker.destroy();
14351435
setStreamInfo.state.type = EAiDetectionState.FINISHED;
1436+
setStreamInfo.highlights = partialHighlights;
14361437
this.updateStream(setStreamInfo);
14371438

14381439
console.log('🔄 addClips', clipData);

0 commit comments

Comments
 (0)