diff --git a/package.json b/package.json
index 8c062e91..b69dae22 100644
--- a/package.json
+++ b/package.json
@@ -39,6 +39,7 @@
},
"license": "GPL-2.0-only",
"devDependencies": {
+ "@adamscybot/react-leaflet-component-marker": "^2.0.0",
"@fontsource/open-sans": "^4.5.14",
"@radix-ui/colors": "^0.1.8",
"@radix-ui/react-checkbox": "^1.0.4",
@@ -50,6 +51,7 @@
"@types/css-modules": "^1.0.5",
"@types/eslint": "^8",
"@types/express": "^4.17.21",
+ "@types/leaflet": "^1",
"@types/lodash": "^4.14.202",
"@types/luxon": "^3.4.2",
"@types/morgan": "^1.9.9",
@@ -85,6 +87,7 @@
"icon-gen": "^4.0.0",
"immer": "^10.0.2",
"ky": "^0.33.1",
+ "leaflet": "^1.9.4",
"luxon": "^3.3.0",
"mkdirp": "^1.0.3",
"mousetrap": "^1.6.5",
@@ -97,6 +100,7 @@
"react-dom": "^18.2.0",
"react-i18next": "^12.1.5",
"react-icons": "^4.1.0",
+ "react-leaflet": "^4.2.1",
"react-lottie-player": "^1.5.0",
"react-sortablejs": "^6.1.4",
"react-syntax-highlighter": "^15.4.3",
diff --git a/src/renderer/src/StreamsSelector.tsx b/src/renderer/src/StreamsSelector.tsx
index d15d838a..0447f8b7 100644
--- a/src/renderer/src/StreamsSelector.tsx
+++ b/src/renderer/src/StreamsSelector.tsx
@@ -12,11 +12,12 @@ import Select from './components/Select';
import { showJson5Dialog } from './dialogs';
import { getStreamFps } from './ffmpeg';
import { deleteDispositionValue } from './util';
-import { getActiveDisposition, attachedPicDisposition } from './util/streams';
+import { getActiveDisposition, attachedPicDisposition, isGpsStream } from './util/streams';
import TagEditor from './components/TagEditor';
import { FFprobeChapter, FFprobeFormat, FFprobeStream } from '../../../ffprobe';
import { CustomTagsByFile, FilesMeta, FormatTimecode, ParamsByStreamId, StreamParams } from './types';
import useUserSettings from './hooks/useUserSettings';
+import tryShowGpsMap from './gps';
const dispositionOptions = ['default', 'dub', 'original', 'comment', 'lyrics', 'karaoke', 'forced', 'hearing_impaired', 'visual_impaired', 'clean_effects', 'attached_pic', 'captions', 'descriptions', 'dependent', 'metadata'];
@@ -209,6 +210,10 @@ const Stream = memo(({ filePath, stream, onToggle, batchSetCopyStreamIds, copySt
loadSubtitleTrackToSegments?.(stream.index);
}, [loadSubtitleTrackToSegments, stream.index]);
+ const onLoadGpsTrackClick = useCallback(async () => {
+ await tryShowGpsMap(filePath, stream.index);
+ }, [filePath, stream.index]);
+
const codecTag = stream.codec_tag !== '0x0000' && stream.codec_tag_string;
return (
@@ -260,6 +265,11 @@ const Stream = memo(({ filePath, stream, onToggle, batchSetCopyStreamIds, copySt
{t('Create segments from subtitles')}
)}
+ {isGpsStream(stream) && (
+
} onClick={onLoadGpsTrackClick}>
+ {t('Show GPS map')}
+
+ )}
diff --git a/src/renderer/src/edlFormats.test.ts b/src/renderer/src/edlFormats.test.ts
index dd55ab6e..84dbf279 100644
--- a/src/renderer/src/edlFormats.test.ts
+++ b/src/renderer/src/edlFormats.test.ts
@@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url';
import { it, describe, expect } from 'vitest';
-import { parseSrt, formatSrt, parseYouTube, formatYouTube, parseMplayerEdl, parseXmeml, parseFcpXml, parseCsv, parseCsvTime, getFrameValParser, formatCsvFrames, getFrameCountRaw, parsePbf, parseDvAnalyzerSummaryTxt, parseCutlist } from './edlFormats';
+import { parseSrtToSegments, formatSrt, parseYouTube, formatYouTube, parseMplayerEdl, parseXmeml, parseFcpXml, parseCsv, parseCsvTime, getFrameValParser, formatCsvFrames, getFrameCountRaw, parsePbf, parseDvAnalyzerSummaryTxt, parseCutlist } from './edlFormats';
// eslint-disable-next-line no-underscore-dangle
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -306,11 +306,11 @@ it('parses pbf', async () => {
});
it('parses srt', async () => {
- expect(parseSrt(await readFixture('sample.srt'))).toMatchSnapshot();
+ expect(parseSrtToSegments(await readFixture('sample.srt'))).toMatchSnapshot();
});
it('format srt', async () => {
- expect(formatSrt(parseSrt(await readFixture('sample.srt')))).toMatchSnapshot();
+ expect(formatSrt(parseSrtToSegments(await readFixture('sample.srt')))).toMatchSnapshot();
});
// https://github.com/mifi/lossless-cut/issues/1664
diff --git a/src/renderer/src/edlFormats.ts b/src/renderer/src/edlFormats.ts
index 38db9be5..e81e7c98 100644
--- a/src/renderer/src/edlFormats.ts
+++ b/src/renderer/src/edlFormats.ts
@@ -370,7 +370,7 @@ export function parseDvAnalyzerSummaryTxt(txt: string) {
// http://www.textfiles.com/uploads/kds-srt.txt
export function parseSrt(text: string) {
- const ret: { start?: number, end?: number, name: string, tags: Record }[] = [];
+ const ret: { start: number, end: number, lines: string[], index: number | undefined }[] = [];
// working state
let subtitleIndexAt: number | undefined;
@@ -380,7 +380,7 @@ export function parseSrt(text: string) {
const flush = () => {
if (start != null && end != null && lines.length > 0) {
- ret.push({ start, end, name: lines.join('\r\n'), tags: { index: subtitleIndexAt != null ? String(subtitleIndexAt) : undefined } });
+ ret.push({ start, end, lines, index: subtitleIndexAt });
}
start = undefined;
end = undefined;
@@ -396,7 +396,7 @@ export function parseSrt(text: string) {
} 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.replaceAll(',', '.');
+ const fixComma = (v: string | undefined) => v!.replaceAll(',', '.');
start = parseTime(fixComma(match[1]))?.time;
end = parseTime(fixComma(match[2]))?.time;
} else if (start != null && end != null) {
@@ -415,6 +415,15 @@ export function parseSrt(text: string) {
return ret;
}
+export function parseSrtToSegments(text: string) {
+ return parseSrt(text).map(({ start, end, lines, index }) => ({
+ start,
+ end,
+ name: lines.join('\r\n'),
+ tags: { index: index != null ? String(index) : undefined },
+ }));
+}
+
export function formatSrt(segments) {
return segments.reduce((acc, segment, index) => `${acc}${index > 0 ? '\r\n' : ''}${index + 1}\r\n${formatDuration({ seconds: segment.start }).replaceAll('.', ',')} --> ${formatDuration({ seconds: segment.end }).replaceAll('.', ',')}\r\n${segment.name || '-'}\r\n`, '');
}
diff --git a/src/renderer/src/edlStore.ts b/src/renderer/src/edlStore.ts
index a57f017e..6328cab4 100644
--- a/src/renderer/src/edlStore.ts
+++ b/src/renderer/src/edlStore.ts
@@ -3,7 +3,7 @@ import i18n from 'i18next';
import type { parse as CueParse } from 'cue-parser';
import invariant from 'tiny-invariant';
-import { parseSrt, 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, parseMplayerEdl, 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';
@@ -52,7 +52,7 @@ export async function loadCue(path: string) {
}
export async function loadSrt(path: string) {
- return parseSrt(await readFile(path, 'utf8'));
+ return parseSrtToSegments(await readFile(path, 'utf8'));
}
export async function saveCsv(path: string, cutSegments) {
diff --git a/src/renderer/src/ffmpeg.ts b/src/renderer/src/ffmpeg.ts
index 658906e3..1cdf8865 100644
--- a/src/renderer/src/ffmpeg.ts
+++ b/src/renderer/src/ffmpeg.ts
@@ -9,7 +9,7 @@ import { pcmAudioCodecs, getMapStreamsArgs, isMov, LiteFFprobeStream } from './u
import { getSuffixedOutPath, isExecaError } from './util';
import { isDurationValid } from './segments';
import { FFprobeChapter, FFprobeFormat, FFprobeProbeResult, FFprobeStream } from '../../../ffprobe';
-import { parseSrt } from './edlFormats';
+import { parseSrt, parseSrtToSegments } from './edlFormats';
const FileType = window.require('file-type');
const { pathExists } = window.require('fs-extra');
@@ -505,7 +505,7 @@ async function renderThumbnail(filePath: string, timestamp: number) {
return URL.createObjectURL(blob);
}
-export async function extractSubtitleTrackToSegments(filePath: string, streamId: number) {
+export async function extractSubtitleTrack(filePath: string, streamId: number) {
const args = [
'-hide_banner',
'-i', filePath,
@@ -515,7 +515,17 @@ export async function extractSubtitleTrackToSegments(filePath: string, streamId:
];
const { stdout } = await runFfmpeg(args);
- return parseSrt(stdout.toString('utf8'));
+ return stdout.toString('utf8');
+}
+
+export async function extractSubtitleTrackToSegments(filePath: string, streamId: number) {
+ const srt = await extractSubtitleTrack(filePath, streamId);
+ return parseSrtToSegments(srt);
+}
+
+export async function extractSrtGpsTrack(filePath: string, streamId: number) {
+ const srt = await extractSubtitleTrack(filePath, streamId);
+ return parseSrt(srt);
}
export async function extractSubtitleTrackVtt(filePath: string, streamId: number) {
@@ -641,7 +651,7 @@ function parseTimecode(str: string, frameRate?: number | undefined) {
export function getTimecodeFromStreams(streams: FFprobeStream[]) {
console.log('Trying to load timecode');
- let foundTimecode;
+ let foundTimecode: number | undefined;
streams.find((stream) => {
try {
if (stream.tags && stream.tags['timecode']) {
diff --git a/src/renderer/src/gps.tsx b/src/renderer/src/gps.tsx
new file mode 100644
index 00000000..191e28f7
--- /dev/null
+++ b/src/renderer/src/gps.tsx
@@ -0,0 +1,75 @@
+import { MapContainer, Popup, TileLayer } from 'react-leaflet';
+import { Marker } from '@adamscybot/react-leaflet-component-marker';
+import { Trans } from 'react-i18next';
+import 'leaflet/dist/leaflet.css';
+import { FaMapMarkerAlt } from 'react-icons/fa';
+
+import { extractSrtGpsTrack } from './ffmpeg';
+import { ReactSwal } from './swal';
+import { handleError } from './util';
+
+
+export default async function tryShowGpsMap(filePath: string, streamIndex: number) {
+ try {
+ const subtitles = await extractSrtGpsTrack(filePath, streamIndex);
+ const gpsPoints = subtitles.flatMap((subtitle) => {
+ const firstLine = subtitle.lines[0];
+ // example:
+ // "F/2.8, SS 776.89, ISO 100, EV -1.0, GPS (15.0732, 67.9771, 19), D 67.78m, H 20.30m, H.S 1.03m/s, V.S 0.00m/s"
+ const gpsMatch = firstLine?.match(/^\s*([^,]+),\s*SS\s+([^,]+),\s*ISO\s+([^,]+),\s*EV\s+([^,]+),\s*GPS\s+\(([^,]+),\s*([^,]+),\s*([^,]+)\),\s*D\s+([^m]+)m,\s*H\s+([^m]+)m,\s*H\.S\s+([^m]+)m\/s,\s*V\.S\s+([^m]+)m\/s\s*$/);
+ if (!gpsMatch || firstLine == null) return [];
+ return [{
+ index: subtitle.index,
+ raw: firstLine,
+ f: gpsMatch[1]!,
+ ss: parseFloat(gpsMatch![2]!),
+ iso: parseInt(gpsMatch![3]!, 10),
+ ev: parseFloat(gpsMatch![4]!),
+ lat: parseFloat(gpsMatch![5]!),
+ lng: parseFloat(gpsMatch![6]!),
+ alt: parseFloat(gpsMatch![7]!),
+ distance: parseFloat(gpsMatch![8]!),
+ height: parseFloat(gpsMatch![9]!),
+ horizontalSpeed: parseFloat(gpsMatch![10]!),
+ verticalSpeed: parseFloat(gpsMatch![11]!),
+ }];
+ });
+ // console.log(gpsPoints)
+
+ const firstPoint = gpsPoints[0];
+
+ if (firstPoint == null) {
+ throw new Error('No GPS points found');
+ }
+
+ // https://www.openstreetmap.org/copyright
+ ReactSwal.fire({
+ width: '100%',
+ html: (
+ <>
+ GPS track
+
+
+
+
+ {gpsPoints.map((point, i) => (
+ }>
+
+ Point {i + 1} / {gpsPoints.length}
+ {point.raw}
+
+
+ ))}
+
+ >
+ ),
+ showCloseButton: true,
+ showConfirmButton: false,
+ });
+ } catch (err) {
+ handleError(err);
+ }
+}
diff --git a/src/renderer/src/util/streams.ts b/src/renderer/src/util/streams.ts
index 3b104682..cf62675a 100644
--- a/src/renderer/src/util/streams.ts
+++ b/src/renderer/src/util/streams.ts
@@ -232,7 +232,7 @@ export function shouldCopyStreamByDefault(stream: FFprobeStream) {
export const attachedPicDisposition = 'attached_pic';
-export type LiteFFprobeStream = Pick;
+export type LiteFFprobeStream = Pick;
export function isStreamThumbnail(stream: LiteFFprobeStream) {
return stream && stream.codec_type === 'video' && stream.disposition?.[attachedPicDisposition] === 1;
@@ -241,6 +241,7 @@ export function isStreamThumbnail(stream: LiteFFprobeStream) {
export const getAudioStreams = (streams: T[]) => streams.filter((stream) => stream.codec_type === 'audio');
export const getRealVideoStreams = (streams: T[]) => streams.filter((stream) => stream.codec_type === 'video' && !isStreamThumbnail(stream));
export const getSubtitleStreams = (streams: T[]) => streams.filter((stream) => stream.codec_type === 'subtitle');
+export const isGpsStream = (stream: T) => stream.codec_type === 'subtitle' && stream.tags?.['handler_name'] === '\u0010DJI.Subtitle';
// videoTracks/audioTracks seems to be 1-indexed, while ffmpeg is 0-indexes
const getHtml5TrackId = (ffmpegTrackIndex: number) => String(ffmpegTrackIndex + 1);
diff --git a/yarn.lock b/yarn.lock
index a2dd5863..61e594e1 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -19,6 +19,22 @@ __metadata:
languageName: node
linkType: hard
+"@adamscybot/react-leaflet-component-marker@npm:^2.0.0":
+ version: 2.0.0
+ resolution: "@adamscybot/react-leaflet-component-marker@npm:2.0.0"
+ dependencies:
+ react-is: "npm:^18.0.0"
+ react-reverse-portal: "npm:^2.1.2"
+ type-fest: "npm:^4.10.3"
+ peerDependencies:
+ leaflet: ^1.9.0
+ react: ^18.0.0
+ react-dom: ^18.0.0
+ react-leaflet: ^4.0.0
+ checksum: a5f62246afa519ded23b7f273a3fee36cf6c13782db97add07681234acc01e1e943c33b23ba8aacb73e2481891b14640645d901d55cf6d5322e75dd3dd10c2c8
+ languageName: node
+ linkType: hard
+
"@ampproject/remapping@npm:^2.2.0, @ampproject/remapping@npm:^2.3.0":
version: 2.3.0
resolution: "@ampproject/remapping@npm:2.3.0"
@@ -1617,6 +1633,17 @@ __metadata:
languageName: node
linkType: hard
+"@react-leaflet/core@npm:^2.1.0":
+ version: 2.1.0
+ resolution: "@react-leaflet/core@npm:2.1.0"
+ peerDependencies:
+ leaflet: ^1.9.0
+ react: ^18.0.0
+ react-dom: ^18.0.0
+ checksum: 12ce28b85cf6712a1a7b7c49466b941fc619bc7b1535308bc5711a35f7e89eb16298babfd62f6b3a92e64abf94dcf517b2bc460f59fcf20599821bc6ab3b3048
+ languageName: node
+ linkType: hard
+
"@rollup/rollup-android-arm-eabi@npm:4.19.0":
version: 4.19.0
resolution: "@rollup/rollup-android-arm-eabi@npm:4.19.0"
@@ -1954,6 +1981,13 @@ __metadata:
languageName: node
linkType: hard
+"@types/geojson@npm:*":
+ version: 7946.0.14
+ resolution: "@types/geojson@npm:7946.0.14"
+ checksum: ae511bee6488ae3bd5a3a3347aedb0371e997b14225b8983679284e22fa4ebd88627c6e3ff8b08bf4cc35068cb29310c89427311ffc9322c255615821a922e71
+ languageName: node
+ linkType: hard
+
"@types/hast@npm:^2.0.0":
version: 2.3.4
resolution: "@types/hast@npm:2.3.4"
@@ -2007,6 +2041,15 @@ __metadata:
languageName: node
linkType: hard
+"@types/leaflet@npm:^1":
+ version: 1.9.12
+ resolution: "@types/leaflet@npm:1.9.12"
+ dependencies:
+ "@types/geojson": "npm:*"
+ checksum: ff6dce2f613b97bdc3ceb929e6eeaaa8bef8bbafdf9758935b1d679cbaf76360e366080d77e42da58e41aac146434c5d18c70ec919d37e01e0592f0a4f2e967e
+ languageName: node
+ linkType: hard
+
"@types/lodash@npm:^4.14.202":
version: 4.14.202
resolution: "@types/lodash@npm:4.14.202"
@@ -7414,6 +7457,13 @@ __metadata:
languageName: node
linkType: hard
+"leaflet@npm:^1.9.4":
+ version: 1.9.4
+ resolution: "leaflet@npm:1.9.4"
+ checksum: 7b6a74d503980961a85bdabebf9d1162c26db0e88195800ceea311682b653d621718f2ada3c9aab903a735af9862c9ae278ba550d4429acbd954d43449cd0d77
+ languageName: node
+ linkType: hard
+
"levn@npm:^0.4.1":
version: 0.4.1
resolution: "levn@npm:0.4.1"
@@ -7552,6 +7602,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "lossless-cut@workspace:."
dependencies:
+ "@adamscybot/react-leaflet-component-marker": "npm:^2.0.0"
"@electron/remote": "npm:^2.0.10"
"@fontsource/open-sans": "npm:^4.5.14"
"@octokit/core": "npm:5"
@@ -7565,6 +7616,7 @@ __metadata:
"@types/css-modules": "npm:^1.0.5"
"@types/eslint": "npm:^8"
"@types/express": "npm:^4.17.21"
+ "@types/leaflet": "npm:^1"
"@types/lodash": "npm:^4.14.202"
"@types/luxon": "npm:^3.4.2"
"@types/morgan": "npm:^1.9.9"
@@ -7611,6 +7663,7 @@ __metadata:
immer: "npm:^10.0.2"
json5: "npm:^2.2.2"
ky: "npm:^0.33.1"
+ leaflet: "npm:^1.9.4"
lodash: "npm:^4.17.19"
luxon: "npm:^3.3.0"
mime-types: "npm:^2.1.14"
@@ -7626,6 +7679,7 @@ __metadata:
react-dom: "npm:^18.2.0"
react-i18next: "npm:^12.1.5"
react-icons: "npm:^4.1.0"
+ react-leaflet: "npm:^4.2.1"
react-lottie-player: "npm:^1.5.0"
react-sortablejs: "npm:^6.1.4"
react-syntax-highlighter: "npm:^15.4.3"
@@ -9111,6 +9165,26 @@ __metadata:
languageName: node
linkType: hard
+"react-is@npm:^18.0.0":
+ version: 18.3.1
+ resolution: "react-is@npm:18.3.1"
+ checksum: d5f60c87d285af24b1e1e7eaeb123ec256c3c8bdea7061ab3932e3e14685708221bf234ec50b21e10dd07f008f1b966a2730a0ce4ff67905b3872ff2042aec22
+ languageName: node
+ linkType: hard
+
+"react-leaflet@npm:^4.2.1":
+ version: 4.2.1
+ resolution: "react-leaflet@npm:4.2.1"
+ dependencies:
+ "@react-leaflet/core": "npm:^2.1.0"
+ peerDependencies:
+ leaflet: ^1.9.0
+ react: ^18.0.0
+ react-dom: ^18.0.0
+ checksum: 01cee12dc32e86d0153c989894fdba1c5c50fa41ad8d712352fa616f7b0dd32844aa17bb06c66f7569133675e2946c669090b4c496610cce3fa37c22254ca89f
+ languageName: node
+ linkType: hard
+
"react-lottie-player@npm:^1.5.0":
version: 1.5.0
resolution: "react-lottie-player@npm:1.5.0"
@@ -9131,6 +9205,16 @@ __metadata:
languageName: node
linkType: hard
+"react-reverse-portal@npm:^2.1.2":
+ version: 2.1.2
+ resolution: "react-reverse-portal@npm:2.1.2"
+ peerDependencies:
+ react: ^16.0.0 || ^17.0.0 || ^18.0.0
+ react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0
+ checksum: 4b24fdb1a6727b6585aa63a03ef0e366064f062418555d847ef53645ee3ea91346481897eb90ab5f6b7da2898096da54d6fbde949bc159a8f4296e3b4ba5d904
+ languageName: node
+ linkType: hard
+
"react-sortablejs@npm:^6.1.4":
version: 6.1.4
resolution: "react-sortablejs@npm:6.1.4"
@@ -10951,7 +11035,7 @@ __metadata:
languageName: node
linkType: hard
-"type-fest@npm:^4.23.0":
+"type-fest@npm:^4.10.3, type-fest@npm:^4.23.0":
version: 4.23.0
resolution: "type-fest@npm:4.23.0"
checksum: c411dea83262f9a4453e09ff82e3ac729dd26afc2e68b7a9fe93dd633b1a2bf7bf2bf3e041676497ae8b8411266019b3add91d4fe34b926a82ba09eb41e9e52b