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