From 2510225408a830592e1dcf13099735ab54e6b04d Mon Sep 17 00:00:00 2001 From: Mikael Finstad Date: Sat, 18 Oct 2025 13:51:33 +0800 Subject: [PATCH] support DJI Air 3 GPS from SRT --- .gitignore | 2 ++ src/renderer/src/App.tsx | 3 +- src/renderer/src/StreamsSelector.tsx | 6 ++-- .../src/__snapshots__/edlFormats.test.ts.snap | 12 +++++-- src/renderer/src/edlFormats.test.ts | 17 ++++++--- src/renderer/src/edlFormats.ts | 36 +++++++++++++++++-- src/renderer/src/ffmpeg.ts | 12 ++++++- src/renderer/src/gps.tsx | 23 ++++++++---- src/renderer/src/types.ts | 3 +- src/renderer/src/util/streams.ts | 3 +- 10 files changed, 95 insertions(+), 22 deletions(-) diff --git a/.gitignore b/.gitignore index 4c91e327..264961cf 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,5 @@ node_modules /ffmpeg /app*.log /ts-dist + +tsconfig.*.tsbuildinfo \ No newline at end of file diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 32cbf182..c5dbb12b 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -60,6 +60,7 @@ import { RefuseOverwriteError, extractSubtitleTrackToSegments, mapRecommendedDefaultFormat, getFfCommandLine, + FileMeta, } from './ffmpeg'; import { shouldCopyStreamByDefault, getAudioStreams, getRealVideoStreams, isAudioDefinitelyNotSupported, willPlayerProperlyHandleVideo, doesPlayerSupportHevcPlayback, getSubtitleStreams, enableVideoTrack, enableAudioTrack, canHtml5PlayerPlayStreams, isMatroska } from './util/streams'; import { exportEdlFile, readEdlFile, loadLlcProject, askForEdlImport } from './edlStore'; @@ -133,7 +134,7 @@ function App() { const [customTagsByFile, setCustomTagsByFile] = useState({}); const [paramsByStreamId, setParamsByStreamId] = useState(new Map()); const [detectedFps, setDetectedFps] = useState(); - const [mainFileMeta, setMainFileMeta] = useState<{ streams: FFprobeStream[], formatData: FFprobeFormat, chapters: FFprobeChapter[] }>(); + const [mainFileMeta, setMainFileMeta] = useState<{ streams: FileMeta['streams'], formatData: FFprobeFormat, chapters: FFprobeChapter[] }>(); const [streamsSelectorShown, setStreamsSelectorShown] = useState(false); const [concatDialogVisible, setConcatDialogVisible] = useState(false); const [zoomUnrounded, setZoom] = useState(1); diff --git a/src/renderer/src/StreamsSelector.tsx b/src/renderer/src/StreamsSelector.tsx index eac4e417..85abc107 100644 --- a/src/renderer/src/StreamsSelector.tsx +++ b/src/renderer/src/StreamsSelector.tsx @@ -11,7 +11,7 @@ import Dialog from './components/Dialog'; import AutoExportToggler from './components/AutoExportToggler'; import Select from './components/Select'; import { showJson5Dialog } from './dialogs'; -import { getStreamFps } from './ffmpeg'; +import { FileStream, getStreamFps } from './ffmpeg'; import { deleteDispositionValue } from './util'; import { getActiveDisposition, attachedPicDisposition, isGpsStream } from './util/streams'; import TagEditor from './components/TagEditor'; @@ -161,7 +161,7 @@ const EditStreamDialog = memo(({ editingStream: { streamId: editingStreamId, pat // eslint-disable-next-line react/display-name const Stream = memo(({ filePath, stream, onToggle, toggleCopyStreamIds, copyStream, fileDuration, setEditingStream, onExtractStreamPress, paramsByStreamId, updateStreamParams, formatTimecode, loadSubtitleTrackToSegments, onInfoClick }: { filePath: string, - stream: FFprobeStream, + stream: FileStream, onToggle: (a: number) => void, toggleCopyStreamIds: (filter: (a: FFprobeStream) => boolean) => void, copyStream: boolean, fileDuration: number | undefined, @@ -365,7 +365,7 @@ function StreamsSelector({ }: { mainFilePath: string, mainFileFormatData: FFprobeFormat | undefined, - mainFileStreams: FFprobeStream[], + mainFileStreams: FileStream[], mainFileChapters: FFprobeChapter[] | undefined, isCopyingStreamId: (path: string | undefined, streamId: number) => boolean, toggleCopyStreamId: (path: string, index: number) => void, diff --git a/src/renderer/src/__snapshots__/edlFormats.test.ts.snap b/src/renderer/src/__snapshots__/edlFormats.test.ts.snap index 9c2cd3ab..dbc1c408 100644 --- a/src/renderer/src/__snapshots__/edlFormats.test.ts.snap +++ b/src/renderer/src/__snapshots__/edlFormats.test.ts.snap @@ -71,7 +71,7 @@ exports[`otio 1`] = ` ] `; -exports[`parseGpsLine 1`] = ` +exports[`parseDjiGps1 1`] = ` { "alt": 19, "distance": 67.78, @@ -88,7 +88,7 @@ exports[`parseGpsLine 1`] = ` } `; -exports[`parseGpsLine 2`] = ` +exports[`parseDjiGps1 2`] = ` { "alt": 26, "distance": 0.57, @@ -105,6 +105,14 @@ exports[`parseGpsLine 2`] = ` } `; +exports[`parseDjiGps2 1`] = ` +{ + "altitude": 1245.046, + "lat": 62.229033, + "lng": 6.552877, +} +`; + exports[`parses DV Analyzer Summary.txt 1`] = ` [ { diff --git a/src/renderer/src/edlFormats.test.ts b/src/renderer/src/edlFormats.test.ts index e9f172f2..4b8f6c76 100644 --- a/src/renderer/src/edlFormats.test.ts +++ b/src/renderer/src/edlFormats.test.ts @@ -1,6 +1,6 @@ import { it, describe, expect, test } from 'vitest'; -import { parseSrtToSegments, formatSrt, parseYouTube, formatYouTube, parseMplayerEdl, parseXmeml, parseFcpXml, parseCsv, parseCsvTime, getFrameValParser, formatCsvFrames, getFrameCountRaw, parsePbf, parseDvAnalyzerSummaryTxt, parseCutlist, parseGpsLine, Otio, parseOtio } from './edlFormats'; +import { parseSrtToSegments, formatSrt, parseYouTube, formatYouTube, parseMplayerEdl, parseXmeml, parseFcpXml, parseCsv, parseCsvTime, getFrameValParser, formatCsvFrames, getFrameCountRaw, parsePbf, parseDvAnalyzerSummaryTxt, parseCutlist, parseDjiGps1, Otio, parseOtio, parseDjiGps2 } from './edlFormats'; import { readFixture, readFixtureBinary } from './test/util'; import otioFixture from './test/fixtures/otio'; @@ -365,10 +365,19 @@ it('parses DV Analyzer Summary.txt', async () => { expect(parseDvAnalyzerSummaryTxt(await readFixture('DV Analyzer Summary.txt', 'utf8'))).toMatchSnapshot(); }); -test('parseGpsLine', () => { - expect(parseGpsLine('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')).toMatchSnapshot(); +test('parseDjiGps1', () => { + expect(parseDjiGps1(['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'])).toMatchSnapshot(); // https://github.com/mifi/lossless-cut/issues/2072#issuecomment-2325755148 - expect(parseGpsLine('F/2.8, SS 678.52, ISO 100, EV 0, DZOOM 1.000, GPS (-3.9130, 56.5019, 26), D 0.57m, H 102.40m, H.S 0.00m/s, V.S -0.00m/s')).toMatchSnapshot(); + expect(parseDjiGps1(['F/2.8, SS 678.52, ISO 100, EV 0, DZOOM 1.000, GPS (-3.9130, 56.5019, 26), D 0.57m, H 102.40m, H.S 0.00m/s, V.S -0.00m/s'])).toMatchSnapshot(); +}); + +test('parseDjiGps2', () => { + // SRT format DJI Air 3 + expect(parseDjiGps2([ + 'FrameCnt: 446, DiffTime: 34ms', + '2025-09-15 14:35:09.229', + '[iso: 120] [shutter: 1/3388.79] [fnum: 1.7] [ev: 0] [color_md : default] [focal_len: 24.00] [latitude: 62.229033] [longitude: 6.552877] [rel_alt: 3.700 abs_alt: 1245.046] [ct: 5341] ', + ])).toMatchSnapshot(); }); test('otio', () => { diff --git a/src/renderer/src/edlFormats.ts b/src/renderer/src/edlFormats.ts index ef625fa1..bde36835 100644 --- a/src/renderer/src/edlFormats.ts +++ b/src/renderer/src/edlFormats.ts @@ -477,8 +477,11 @@ export function formatSrt(segments: SegmentBase[]) { 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`, ''); } -export function parseGpsLine(line: string) { - const gpsMatch = line.match(/^\s*([^,]+),\s*SS\s+([^,]+),\s*ISO\s+([^,]+),\s*EV\s+([^,]+)(?:,\s*DZOOM\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*$/); +export function parseDjiGps1(lines: string[]) { + const firstLine = lines[0]; + if (firstLine == null) return undefined; + + const gpsMatch = firstLine.match(/^\s*([^,]+),\s*SS\s+([^,]+),\s*ISO\s+([^,]+),\s*EV\s+([^,]+)(?:,\s*DZOOM\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) return undefined; return { f: gpsMatch[1]!, @@ -496,6 +499,35 @@ export function parseGpsLine(line: string) { }; } +export function parseDjiGps2(lines: string[]) { + const xml = new XMLParser().parse(lines.join('\n')); + invariant(typeof xml.font === 'string'); + const line: string = xml.font.split('\n')[2]; + invariant(line != null); + const records: Record = {}; + const pairsMatch = line.match(/([^\s:[]+\s*:\s*[^\s\]]+)+/g); + if (pairsMatch == null) return undefined; + for (const match of pairsMatch) { + const split = match.split(':'); + if (split.length === 2) { + const [key, value] = split; + if (key != null && value != null) { + records[key.trim()] = value.trim(); + } + } + } + const altitude = parseFloat(records['abs_alt']!); + const lat = parseFloat(records['latitude']!); + const lng = parseFloat(records['longitude']!); + invariant(!Number.isNaN(lat)); + invariant(!Number.isNaN(lng)); + return { + altitude: Number.isNaN(altitude) ? undefined : altitude, + lat, + lng, + }; +} + const otioSchema = z.object({ OTIO_SCHEMA: z.string().refine((val) => val.startsWith('Timeline.'), { message: 'Invalid OTIO schema' }), name: z.string(), diff --git a/src/renderer/src/ffmpeg.ts b/src/renderer/src/ffmpeg.ts index 3e8859ae..d8542d1d 100644 --- a/src/renderer/src/ffmpeg.ts +++ b/src/renderer/src/ffmpeg.ts @@ -360,8 +360,15 @@ export async function readFileMeta(filePath: string) { console.log('ffprobe stdout:', decoded ?? stdout); throw new Error('ffprobe returned malformed data'); } - const { streams = [], format, chapters = [] } = parsedJson; + const { format, chapters = [] } = parsedJson; invariant(format != null); + + const streams = (parsedJson.streams ?? []).map((s) => { + if (/DJI_[^/\\]+SRT$/.test(filePath)) { + return { ...s, guessedType: 'dji-gps-srt' as const }; + } + return { ...s, guessedType: undefined }; + }); return { format, streams, chapters }; } catch (err) { if (isExecaError(err)) { @@ -371,6 +378,9 @@ export async function readFileMeta(filePath: string) { } } +export type FileMeta = Awaited>; +export type FileStream = FileMeta['streams'][number]; + async function renderThumbnail(filePath: string, timestamp: number, signal: AbortSignal) { const args = [ '-ss', String(timestamp), diff --git a/src/renderer/src/gps.tsx b/src/renderer/src/gps.tsx index ad47b75e..619fbac5 100644 --- a/src/renderer/src/gps.tsx +++ b/src/renderer/src/gps.tsx @@ -7,27 +7,36 @@ import { FaMapMarkerAlt } from 'react-icons/fa'; import { extractSrtGpsTrack } from './ffmpeg'; import { ReactSwal } from './swal'; import { handleError } from './util'; -import { parseGpsLine } from './edlFormats'; +import { parseDjiGps1, parseDjiGps2 } from './edlFormats'; 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]; + const allGpsPoints = subtitles.flatMap((subtitle) => { const { index } = subtitle; - if (firstLine == null || index == null) return []; + if (index == null) return []; - const parsed = parseGpsLine(firstLine); + const parsed = parseDjiGps1(subtitle.lines) ?? parseDjiGps2(subtitle.lines); if (parsed == null) return []; return [{ ...parsed, index, - raw: firstLine, + raw: subtitle.lines, }]; }); - // console.log(gpsPoints) + // console.log(allGpsPoints) + + // limit number of points, or else severe map slowdown + const maxPointsToShow = 500; + let gpsPoints = allGpsPoints; + if (allGpsPoints.length > maxPointsToShow) { + gpsPoints = Array.from({ length: maxPointsToShow }).flatMap((_, i) => { + const p = allGpsPoints[Math.floor(i * (allGpsPoints.length / maxPointsToShow))]; + return p != null ? [p] : []; + }); + } const firstPoint = gpsPoints[0]; diff --git a/src/renderer/src/types.ts b/src/renderer/src/types.ts index f711b76b..735ea43d 100644 --- a/src/renderer/src/types.ts +++ b/src/renderer/src/types.ts @@ -1,6 +1,7 @@ import type { MenuItem, MenuItemConstructorOptions } from 'electron'; import { z } from 'zod'; import { FFprobeChapter, FFprobeFormat, FFprobeStream } from '../../../ffprobe'; +import type { FileStream } from './ffmpeg'; export interface ChromiumHTMLVideoElement extends HTMLVideoElement { @@ -128,7 +129,7 @@ export type ContextMenuTemplate = (MenuItemConstructorOptions | MenuItem)[]; export type ExportMode = 'segments_to_chapters' | 'merge' | 'merge+separate' | 'separate'; export type FilesMeta = Record diff --git a/src/renderer/src/util/streams.ts b/src/renderer/src/util/streams.ts index d4cdf8ee..f971a3cf 100644 --- a/src/renderer/src/util/streams.ts +++ b/src/renderer/src/util/streams.ts @@ -1,6 +1,7 @@ import invariant from 'tiny-invariant'; import { FFprobeStream, FFprobeStreamDisposition } from '../../../../ffprobe'; import { AllFilesMeta, ChromiumHTMLAudioElement, ChromiumHTMLVideoElement, CopyfileStreams, LiteFFprobeStream } from '../types'; +import type { FileStream } from '../ffmpeg'; // taken from `ffmpeg -codecs` @@ -259,7 +260,7 @@ export function isStreamThumbnail(stream: Pick>(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'; +export const isGpsStream = >(stream: T) => stream.codec_type === 'subtitle' && (stream.tags?.['handler_name'] === '\u0010DJI.Subtitle' || stream.guessedType); // videoTracks/audioTracks seems to be 1-indexed, while ffmpeg is 0-indexes const getHtml5TrackId = (ffmpegTrackIndex: number) => String(ffmpegTrackIndex + 1);