1
0
mirror of https://github.com/mifi/lossless-cut.git synced 2024-11-21 18:02:35 +01:00

support rendering gps map #2072

experimental and only supports DJI SRT
This commit is contained in:
Mikael Finstad 2024-08-05 23:23:06 +02:00
parent 08e29b2135
commit cfd51d6ce5
No known key found for this signature in database
GPG Key ID: 25AB36E3E81CBC26
9 changed files with 208 additions and 15 deletions

View File

@ -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",

View File

@ -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')}
</Menu.Item>
)}
{isGpsStream(stream) && (
<Menu.Item icon={<MdSubtitles color="black" />} onClick={onLoadGpsTrackClick}>
{t('Show GPS map')}
</Menu.Item>
)}
</Menu.Group>
<Menu.Divider />
<Menu.Group>

View File

@ -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

View File

@ -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<string, string | undefined> }[] = [];
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`, '');
}

View File

@ -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) {

View File

@ -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']) {

75
src/renderer/src/gps.tsx Normal file
View File

@ -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: (
<>
<div style={{ marginBottom: '1em' }}><Trans>GPS track</Trans></div>
<MapContainer center={[firstPoint.lng, firstPoint.lat]} zoom={16} style={{ height: 500 }}>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{gpsPoints.map((point, i) => (
<Marker key={point.index} position={[point.lng, point.lat]} icon={<FaMapMarkerAlt color="#af0e0e" size={20} />}>
<Popup>
<div>Point {i + 1} / {gpsPoints.length}</div>
{point.raw}
</Popup>
</Marker>
))}
</MapContainer>
</>
),
showCloseButton: true,
showConfirmButton: false,
});
} catch (err) {
handleError(err);
}
}

View File

@ -232,7 +232,7 @@ export function shouldCopyStreamByDefault(stream: FFprobeStream) {
export const attachedPicDisposition = 'attached_pic';
export type LiteFFprobeStream = Pick<FFprobeStream, 'index' | 'codec_type' | 'codec_tag' | 'codec_name' | 'disposition'>;
export type LiteFFprobeStream = Pick<FFprobeStream, 'index' | 'codec_type' | 'codec_tag' | 'codec_name' | 'disposition' | 'tags'>;
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 = <T extends LiteFFprobeStream>(streams: T[]) => streams.filter((stream) => stream.codec_type === 'audio');
export const getRealVideoStreams = <T extends LiteFFprobeStream>(streams: T[]) => streams.filter((stream) => stream.codec_type === 'video' && !isStreamThumbnail(stream));
export const getSubtitleStreams = <T extends LiteFFprobeStream>(streams: T[]) => streams.filter((stream) => stream.codec_type === 'subtitle');
export const isGpsStream = <T extends LiteFFprobeStream>(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);

View File

@ -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