support DJI Air 3 GPS from SRT

pull/2352/head
Mikael Finstad 1 week ago
parent 086d124194
commit 2510225408
No known key found for this signature in database
GPG Key ID: 25AB36E3E81CBC26

2
.gitignore vendored

@ -18,3 +18,5 @@ node_modules
/ffmpeg
/app*.log
/ts-dist
tsconfig.*.tsbuildinfo

@ -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<CustomTagsByFile>({});
const [paramsByStreamId, setParamsByStreamId] = useState<ParamsByStreamId>(new Map());
const [detectedFps, setDetectedFps] = useState<number>();
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);

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

@ -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`] = `
[
{

@ -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([
'<font size="28">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] </font>',
])).toMatchSnapshot();
});
test('otio', () => {

@ -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<string, string> = {};
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(),

@ -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<ReturnType<typeof readFileMeta>>;
export type FileStream = FileMeta['streams'][number];
async function renderThumbnail(filePath: string, timestamp: number, signal: AbortSignal) {
const args = [
'-ss', String(timestamp),

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

@ -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<string, {
streams: FFprobeStream[];
streams: FileStream[];
formatData: FFprobeFormat;
chapters: FFprobeChapter[];
}>

@ -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<FFprobeStream, 'codec_type' | 'di
export const getAudioStreams = <T extends Pick<FFprobeStream, 'codec_type'>>(streams: T[]) => streams.filter((stream) => stream.codec_type === 'audio');
export const getRealVideoStreams = <T extends Pick<FFprobeStream, 'codec_type' | 'disposition'>>(streams: T[]) => streams.filter((stream) => stream.codec_type === 'video' && !isStreamThumbnail(stream));
export const getSubtitleStreams = <T extends Pick<FFprobeStream, 'codec_type'>>(streams: T[]) => streams.filter((stream) => stream.codec_type === 'subtitle');
export const isGpsStream = <T extends Pick<FFprobeStream, 'codec_type' | 'tags'>>(stream: T) => stream.codec_type === 'subtitle' && stream.tags?.['handler_name'] === '\u0010DJI.Subtitle';
export const isGpsStream = <T extends Pick<FileStream, 'guessedType' | 'codec_type' | 'tags'>>(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);

Loading…
Cancel
Save