UNPKG

dji_srt_parser

Version:

Parses and interprets DJI's drones SRT metadata

1,201 lines (1,094 loc) 38.4 kB
const tzlookup = require('tz-lookup'); const moment = require('moment-timezone'); const toMGJSON = require('./modules/toMGJSON'); const isoDateRegex = /[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?Z/; function DJI_SRT_Parser() { this.fileName = ''; this.metadata = {}; this.rawMetadata = []; this.smoothened = 0; this.millisecondsSample = 0; this.loaded = false; this.isMultiple = false; this.customProperties = {}; } DJI_SRT_Parser.prototype.srtToObject = function (srt) { const maybeParseNumbers = d => { return isNum(d) ? Number(d) : d; }; //convert SRT strings file into array of objects let converted = []; const timecodeRegEx = /(\d{2}:\d{2}:\d{2},\d{3})\s-->\s/; const packetRegEx = /^\d+$/; const arrayRegEx = /\b([A-Z_a-z]+)\(([-\+\w.,/]+)\)/g; const autelGPSRegex = /\bGPS\((W|E):([\d.]+),(N|S):([\d.]+),(-?[\d.]+)m/g; const valueRegEx = /\b([A-Z_a-z]+)\s?:[\s\[a-z_A-Z\]]?([-\+\d./]+)\w{0,3}\b/g; const dateRegEx = /\d{4}[-.]\d{1,2}[-.]\d{1,2} \d{1,2}:\d{2}:\d{2,}/; const accurateDateRegex = /(\d{4}[-.]\d{1,2}[-.]\d{1,2} \d{1,2}:\d{2}:\d{2}),(\w{3}),(\w{3})/g; const accurateDateRegex2 = /(\d{4}[-.]\d{1,2}[-.]\d{1,2} \d{1,2}:\d{2}:\d{2})[,.](\w{3})/g; // Identify DJI FPV for altitude fix const isDJIFPV = /font size="28"/.test(srt) && /\d{4}-\d{1,2}-\d{1,2} \d{1,2}:\d{2}:\d{2}.\d{3}/.test(srt) && /\[altitude: -?\d.*\]/.test(srt); //Split difficult Phantom4Pro format srt = srt .replace(/.*-->.*/g, match => match.replace(/,/g, ':separator:')) .replace(/\(([^\)]+)\)/g, match => match.replace(/,/g, ':separator:').replace(/\s/g, '') ) .replace(/, /g, ' ') .replace(/Â|°|(B0)/g, '') .replace(/\:separator\:/g, ','); //Split others srt = srt .split(/[\n\r]/) .map(l => l.trim()) .map(l => l .replace(/([a-zA-Z])\s([-\d])/g, '$1:$2') .replace(/([a-zA-Z])\s\(/g, '$1(') .replace(/([a-zA-Z])\.([a-zA-Z])/g, '$1_$2') .replace(/([a-zA-Z])\/(\d)/g, '$1:$2') ) .filter(l => l.length); srt.forEach(line => { let match; if (packetRegEx.test(line)) { //new packet converted.push({}); } else if ((match = timecodeRegEx.exec(line))) { converted[converted.length - 1].TIMECODE = match[1]; } else { while ((match = arrayRegEx.exec(line))) { converted[converted.length - 1][match[1]] = match[2] .split(',') .map(d => maybeParseNumbers(d)); } if ((match = autelGPSRegex.exec(line))) { converted[converted.length - 1].LONGITUDE = +match[4] * (match[3] === 'N' ? 1 : -1); converted[converted.length - 1].LATITUDE = +match[2] * (match[1] === 'E' ? 1 : -1); converted[converted.length - 1].ALTITUDE = +match[5]; } while ((match = valueRegEx.exec(line))) { converted[converted.length - 1][match[1]] = maybeParseNumbers(match[2]); } if ((match = isoDateRegex.exec(line))) { converted[converted.length - 1].DATE = line; } else if ((match = accurateDateRegex.exec(line))) { converted[converted.length - 1].DATE = match[1] + ':' + match[2] + '.' + match[3]; } else if ((match = accurateDateRegex2.exec(line))) { converted[converted.length - 1].DATE = match[1] + '.' + match[2]; } else if ((match = dateRegEx.exec(line))) { converted[converted.length - 1].DATE = match[0].replace( /(:\d{2})(\d+)\d*$/, '$1.$2' ); } else if (isDJIFPV && /\[altitude: -?\d.*\]/.test(line)) { // Correct altitude divided by 10 problem in DJI FPV drone converted[converted.length - 1].altitude = String( +converted[converted.length - 1].altitude * 10 ); } } }); if (converted.length < 1 || Object.entries(converted[0]).length === 0) { console.error('Error converting object'); return null; } return converted; }; DJI_SRT_Parser.prototype.millisecondsPerSample = function ( metadata, milliseconds ) { // get the smoothed array already saved and interpreted let newArr = metadata.packets; let millisecondsPerSampleTIMECODE = function (amount) { let lastTimecode = 0; let newResArr = []; for (let i = 0; i < newArr.length; i++) { let millisecondsFromTimecode = getMilliseconds(newArr[i].TIMECODE); if (millisecondsFromTimecode < lastTimecode) { continue; } newResArr.push(newArr[i]); // We save this value with the seconds parameters applyed lastTimecode = millisecondsFromTimecode + amount; } return newResArr; }; // Calculate seconds from the timecode let getMilliseconds = function (timecode) { let m = timecode.split(','); // Split on the comma of milliseconds let t = m[0].split(':'); // Split on time separators let milliseconds = (+t[0] * 60 * 60 + +t[1] * 60 + +t[2]) * 1000; return Number(milliseconds) + Number(m[1]); }; if (newArr[0].TIMECODE) { // If the value is 0, don't do anything if (milliseconds !== 0) { newArr = millisecondsPerSampleTIMECODE(milliseconds); } this.millisecondsSample = milliseconds; } return newArr; }; DJI_SRT_Parser.prototype.interpretMetadata = function (arr, smooth) { // Forcing srt to have one information line plus the timecode. Preventing empty lines and incomplete data in the array, something frequent at the end of the DJI´s SRTs. arr = arr.filter(value => Object.keys(value).length > 1); // Do not process empty files if (!arr.length) return null; let fixDateUTC; // Fix duplicated dates const fixDates = function (arr) { let computed = JSON.parse(JSON.stringify(arr)); let offset = 0; if (fixDateUTC) { const sample = computed.find( c => c.GPS && c.GPS.LATITUDE != null && c.GPS.LONGITUDE != null && c.GPS.LATITUDE != 'n/a' && c.GPS.LONGITUDE != 'n/a' && c.DATE != null ); if (sample) { try { const tz = tzlookup(sample.GPS.LATITUDE, sample.GPS.LONGITUDE); if (tz) { let d = moment(sample.DATE); offset = (d.utcOffset() - d.tz(tz).utcOffset()) * 60 * 1000; } } catch (error) { console.warn(error); } } computed.forEach(c => (c.DATE += offset)); } computed.forEach((c, i, arr) => { if (i > 0 && i < arr.length - 1 && c.DATE === arr[i + 1].DATE) { const diff = c.DATE - arr[i - 1].DATE; c.DATE = c.DATE - diff / 2; } }); return computed; }; let computeSpeed = function (arr) { //computes 3 types of speed in km/h let computed = JSON.parse(JSON.stringify(arr)); let measure = function (lat1, lon1, lat2, lon2) { // generally used geo measurement function. Source: https://stackoverflow.com/questions/639695/how-to-convert-latitude-or-longitude-to-meters if ( [lat1, lon1, lat2, lon2].reduce( (acc, val) => (!isNum(val) ? true : acc), false ) ) { return 0; //set distance to 0 if there are null or nans in positions } var R = 6378.137; // Radius of earth in KM var dLat = (lat2 * Math.PI) / 180 - (lat1 * Math.PI) / 180; var dLon = (lon2 * Math.PI) / 180 - (lon1 * Math.PI) / 180; var a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat2 * Math.PI) / 180) * Math.sin(dLon / 2) * Math.sin(dLon / 2); var c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); var d = R * c; return d * 1000; // meters }; let accDistance = 0; computed = computed.map((pck, i, cmp) => { let result = JSON.parse(JSON.stringify(pck)); result.SPEED = { TWOD: 0, VERTICAL: 0, THREED: 0 }; result.DISTANCE = 0; if (i > 0) { let origin2d = [cmp[i - 1].GPS.LATITUDE, cmp[i - 1].GPS.LONGITUDE]; let destin2d = [pck.GPS.LATITUDE, pck.GPS.LONGITUDE]; let distance2D = measure( origin2d[0], origin2d[1], destin2d[0], destin2d[1] ); distance2D /= 1000; let distanceVert = getElevation(pck) - getElevation(cmp[i - 1]); distanceVert /= 1000; let distance3D = Math.hypot(distance2D, distanceVert); let time = 1; //Fallback time, 1 second if (pck.DIFFTIME != null) { time = pck.DIFFTIME / 1000; } else if (pck.DATE) { time = (new Date(pck.DATE).getTime() - new Date(cmp[i - 1].DATE).getTime()) / 1000.0; //seconds } else if (pck.TIMECODE) { const parseTC = /(\d\d):(\d\d):(\d\d),(\d{3})/; let match = pck.TIMECODE.match(parseTC); const useDate = new Date( 0, 0, 0, match[1], match[2], match[3], match[4] ); match = cmp[i - 1].TIMECODE.match(parseTC); const prevDate = new Date( 0, 0, 0, match[1], match[2], match[3], match[4] ); time = (new Date(useDate).getTime() - new Date(prevDate).getTime()) / 1000.0; //seconds } time = time == 0 ? 1 : time; //make sure we are not dividing by zero, not sure why sometimes two packets have the same timestamp time /= 60 * 60; accDistance += distance3D * 1000; result.DISTANCE = accDistance; //If preset speeds set, copy them if (result.SPEED_TWOD != null) result.SPEED.TWOD = result.SPEED_TWOD; else result.SPEED.TWOD = distance2D / time; delete result.SPEED_TWOD; if (result.SPEED_VERTICAL != null) result.SPEED.VERTICAL = result.SPEED_VERTICAL; else result.SPEED.VERTICAL = distanceVert / time; delete result.SPEED_VERTICAL; if (result.SPEED_THREED != null) result.SPEED.THREED = result.SPEED_THREED; else result.SPEED.THREED = distance3D / time; delete result.SPEED_THREED; } return result; }); return computed; }; let computeStats = function (arr) { let statsObject = function (obj) { if (obj.constructor === Object && Object.keys(obj).length === 0) return null; let result = {}; for (let elt in obj) { if (elt !== 'TIMECODE') { //IGNORE TIMECODES FOR STATS if (typeof obj[elt] === 'object' && obj[elt] != null) { result[elt] = statsObject(obj[elt]); } else { result[elt] = { min: 0, max: 0, avg: 0 }; } } } return result; }; let recursiveStatsExtraction = function (res, arr) { let deepEqual = function (a, b) { if (a === b) return true; if ( a == null || typeof a != 'object' || b == null || typeof b != 'object' ) return false; var propsInA = 0, propsInB = 0; for (var prop in a) propsInA += 1; for (var prop in b) { propsInB += 1; if (!(prop in a) || !deepEqual(a[prop], b[prop])) return false; } return propsInA == propsInB; }; let result = res; for (let elt in result) { let select = arr.map(pck => { //remove undefined values in mixed srt files if (pck) return pck[elt]; }); if (elt === 'HOME') { //fill fields that do not use standard stats let allHomes = []; select.reduce((acc, val) => { //save different homes if present if (!deepEqual(val, acc)) allHomes.push(val); return val; }, []); result[elt] = allHomes; } else if (elt === 'DATE') { result[elt] = select[0]; } else if (elt === 'TIMECODE') { //DO NOTHING } else if (typeof select[0] === 'object' && select[0] != null) { recursiveStatsExtraction(result[elt], select); } else { result[elt].min = select.reduce( (acc, val) => (val < acc && isNum(val) ? val : acc), Infinity ); result[elt].max = select.reduce( (acc, val) => (val > acc && isNum(val) ? val : acc), -Infinity ); result[elt].avg = select.reduce((acc, val) => (isNum(val) ? acc + val : acc), 0) / select.length; } } return result; }; let result = statsObject(arr[0]); if (result.constructor === Object && Object.keys(result).length === 0) return null; result = recursiveStatsExtraction(result, arr); if (arr[arr.length - 1].DIFFTIME != undefined) { result.DURATION = arr[arr.length - 1].DIFFTIME; //duration of video in milliseconds } else if (arr[arr.length - 1].DATE != undefined) { result.DURATION = new Date(arr[arr.length - 1].DATE) - new Date(arr[0].DATE); //duration of video in milliseconds } if (arr[arr.length - 1].DISTANCE != null) { result.DISTANCE = arr[arr.length - 1].DISTANCE; //dsitance of flight in meters } return result; }; let interpretPacket = function (pck, seemsRadians) { //Fix strange RTK format const fixRTK = function (pck) { if (!pck.GPS && pck.RTK) { const result = JSON.parse(JSON.stringify(pck)); result.GPS = JSON.parse(JSON.stringify(result.RTK)); delete result.RTK; return result; } return pck; }; let interpretItem = function (key, datum) { //interprets known values to most useful data type let interpretedI = {}; if (seemsRadians && (key === 'latitude' || key === 'longtitude')) { datum *= 180 / Math.PI; } if (key.toUpperCase() === 'GPS') { const m300format = /^\d+\.\d+M$/.test(datum[2]); const idx = { lat: m300format ? 0 : 1, lon: m300format ? 1 : 0 }; interpretedI = { LATITUDE: isNum(datum[idx.lat]) ? Number(datum[idx.lat]) : 'n/a', LONGITUDE: isNum(datum[idx.lon]) ? Number(datum[idx.lon]) : 'n/a' }; if (m300format && isNum(datum[2].replace(/[a-z]/gi, ''))) { interpretedI.PRECISION = Number(datum[2].replace(/[a-z]/gi, '')); } else if (isNum(datum[2])) { interpretedI.SATELLITES = Number(datum[2]); } else if (/\dm$/.test(datum[2])) { interpretedI.ALTITUDE = +datum[2].replace(/m$/, ''); } } else if (key.toUpperCase() === 'F_PRY') { interpretedI = { 1: isNum(datum[1]) ? Number(datum[1]) : 'n/a', 2: isNum(datum[0]) ? Number(datum[0]) : 'n/a', 3: isNum(datum[2]) ? Number(datum[2]) : 'n/a' }; } else if (key.toUpperCase() === 'G_PRY') { interpretedI = { 1: isNum(datum[1]) ? Number(datum[1]) : 'n/a', 2: isNum(datum[0]) ? Number(datum[0]) : 'n/a', 3: isNum(datum[2]) ? Number(datum[2]) : 'n/a' }; } else if (key.toUpperCase() === 'HOME') { interpretedI = { LATITUDE: Number(datum[1]), LONGITUDE: Number(datum[0]) }; if (datum.length > 2) { interpretedI.ALTITUDE = +datum[2].replace(/m$/, ''); } } else if (key.toUpperCase() === 'TIMECODE') { interpretedI = datum; } else if (key.toUpperCase() === 'DATE') { let date = datum; // Fix date offset if not Zulu if (fixDateUTC == null) fixDateUTC = !/.+Z$/.test(datum); if (!isoDateRegex.exec(datum)) date = datum .replace(/\./g, '-') .replace(' ', 'T') .replace(/-([0-9](\b|[a-zA-Z]))/g, '-0$1') .replace(/:(\w{3})-(\w{3})$/g, '.$1') .replace(/-(\d+Z?)$/, '.$1'); interpretedI = new Date(date).getTime(); } else if (key.toUpperCase() === 'EV') { interpretedI = eval(datum); } else if (key.toUpperCase() === 'SHUTTER') { interpretedI = isNum(datum) ? Number(datum) : Number(datum.replace('1/', '')); } else if (key.toUpperCase() === 'FNUM' && Number(datum) > 50) { //convert f numbers represented like 280 interpretedI = Number(datum) / 100; } else if (Array.isArray(datum)) { interpretedI = datum.map(d => isNum(d) ? Number(d) : Number(d.replace(/[a-zA-Z]/g, '')) ); } else { interpretedI = isNum(datum) ? Number(datum) : Number(datum.replace(/[a-zA-Z]/g, '')); } if ( interpretedI.constructor === Object && Object.keys(interpretedI).length === 0 ) return null; return interpretedI; }; let fillMissingFields = function (pckt) { let replaceKey = function (o, old_key, new_key) { if (old_key !== new_key) { Object.defineProperty( o, new_key, Object.getOwnPropertyDescriptor(o, old_key) ); delete o[old_key]; } }; let references = { //translate keys form various formats SHUTTER: ['TV', 'SS'], FNUM: ['IR', 'F'], ALTITUDE: ['H', 'ABS_ALT'], BAROMETER: ['REL_ALT'] }; for (let key in references) { if (pckt[key] == undefined) { for (const match of references[key]) { if (pckt[match] != undefined) { replaceKey(pckt, match, key); break; } } } } //Make up date with timecode if not present if (!pckt['DATE'] && pckt['TIMECODE']) { pckt['DATE'] = new Date( new Date() .toISOString() .replace(/\d{2}:\d{2}:\d{2}.\d{3}Z/i, pckt['TIMECODE']) .replace(/,/, '.') ).getTime(); } const seemsAutel = (pckt['N'] != null || pckt['S'] != null) && (pckt['W'] != null || pckt['E'] != null) && pckt['NUM'] != null; //Mavic 2 style let latitude = seemsAutel ? pckt['N'] != null ? pckt['N'] : -pckt['S'] : pckt['LATITUDE']; let longitude = seemsAutel ? pckt['E'] != null ? pckt['E'] : -pckt['W'] : pckt['LONGITUDE'] || pckt['LONGTITUDE']; let satellites = pckt['SATELLITES']; // NUM in autel seems to be F number sometimes // let satellites = seemsAutel ? pckt['NUM'] : pckt['SATELLITES']; if (seemsAutel) pckt['FNUM'] = pckt['NUM']; let precision = pckt['PRECISION']; if (seemsAutel) { delete pckt['N']; delete pckt['S']; delete pckt['W']; delete pckt['E']; delete pckt['NUM']; } // If one parameter exists, we fill the other later if (latitude != undefined || longitude != undefined) { pckt.GPS = { LONGITUDE: longitude, LATITUDE: latitude, SATELLITES: satellites, PRECISION: precision }; } return pckt; }; let interpretedP = {}; pck = fixRTK(pck); for (let item in pck) { interpretedP[item.toUpperCase()] = interpretItem(item, pck[item]); } interpretedP = fillMissingFields(interpretedP); if ( interpretedP.constructor === Object && Object.keys(interpretedP).length === 0 ) return null; return interpretedP; }; let smoothenGPS = function (arr, amount) { //averages positions with the specified surrounding seconds. Necessary due to DJI's SRT logs low precision let smoothArr = JSON.parse(JSON.stringify(arr)); for (let i = 0; i < arr.length; i++) { let start = parseInt(i - amount); let end = parseInt(i + amount); const elevKey = getElevationKey(arr[i]); let sums = { LATITUDE: 0, LONGITUDE: 0, [elevKey]: 0 }; let latSkips = 0; let lonSkips = 0; let altSkips = 0; for (let j = start; j < end; j++) { let k = Math.max(Math.min(j, arr.length - 1), 0); if (isNum(arr[k].GPS.LATITUDE)) { sums.LATITUDE += arr[k].GPS.LATITUDE; } else { latSkips++; } if (isNum(arr[k].GPS.LONGITUDE)) { sums.LONGITUDE += arr[k].GPS.LONGITUDE; } else { lonSkips++; } if (isNum(arr[k][elevKey])) { sums[elevKey] += arr[k][elevKey]; } else { altSkips++; } } smoothArr[i].GPS.LATITUDE = sums.LATITUDE / (amount * 2 - latSkips); smoothArr[i].GPS.LONGITUDE = sums.LONGITUDE / (amount * 2 - lonSkips); smoothArr[i][elevKey] = sums[elevKey] / (amount * 2 - altSkips); } return smoothArr; }; // Mavic Air 2 has a bug where coords are in radians. This tries to detect this error and convert to degrees const deduceRadians = function (arr) { let maxLat = -Infinity; let minLat = Infinity; let maxLon = -Infinity; let minLon = Infinity; for (let i = 0; i < arr.length; i++) { if (arr[i].latitude && arr[i].longtitude) { maxLat = Math.max(maxLat, arr[i].latitude); maxLon = Math.max(maxLon, arr[i].longtitude); minLat = Math.min(minLat, arr[i].latitude); minLon = Math.min(minLon, arr[i].longtitude); } } if (maxLon > Math.PI) return false; if (maxLon < -Math.PI) return false; if (maxLat > Math.PI / 2) return false; if (maxLat < -Math.PI / 2) return false; // This distance in radians would probably be impossible for a drone if (maxLat - minLat > 0.001) return false; if (maxLon - minLon > 0.001) return false; return true; }; const seemsRadians = deduceRadians(arr); // If there was a time resampled array already created let newArr = arr.map(pck => interpretPacket(pck, seemsRadians)); //Fix repeated dates newArr = fixDates(newArr); for (let i = 1; i < newArr.length; i++) { //loop back and forth to fill missing gps data with neighbours if (newArr[i].GPS) { try { if (!isNum(newArr[i].GPS.LATITUDE)) newArr[i].GPS.LATITUDE = newArr[i - 1].GPS.LATITUDE; if (!isNum(newArr[i].GPS.LONGITUDE)) newArr[i].GPS.LONGITUDE = newArr[i - 1].GPS.LONGITUDE; if (newArr[i].GPS.SATELLITES && !isNum(newArr[i].GPS.SATELLITES)) newArr[i].GPS.SATELLITES = newArr[i - 1].GPS.SATELLITES; if (newArr[i].GPS.PRECISION && !isNum(newArr[i].GPS.PRECISION)) newArr[i].GPS.PRECISION = newArr[i - 1].GPS.PRECISION; } catch (_) {} } } for (let i = newArr.length - 2; i >= 0; i--) { if (newArr[i].GPS) { try { if (!isNum(newArr[i].GPS.LATITUDE)) newArr[i].GPS.LATITUDE = newArr[i + 1].GPS.LATITUDE; if (!isNum(newArr[i].GPS.LONGITUDE)) newArr[i].GPS.LONGITUDE = newArr[i + 1].GPS.LONGITUDE; if ( newArr[i].GPS.SATELLITES != null && !isNum(newArr[i].GPS.SATELLITES) ) newArr[i].GPS.SATELLITES = newArr[i + 1].GPS.SATELLITES; if (newArr[i].GPS.PRECISION != null && !isNum(newArr[i].GPS.PRECISION)) newArr[i].GPS.PRECISION = newArr[i + 1].GPS.PRECISION; } catch (_) {} } } let smoothing = smooth != undefined ? smooth : 4; smoothing = smoothing >= 0 ? smoothing : 0; // Only accept parameters with GPS to prevent fatal errors let filteredGPSArr = newArr.filter(arr => arr.GPS); if (filteredGPSArr.length) { if (smoothing !== 0) { filteredGPSArr = smoothenGPS(filteredGPSArr, smoothing); } this.smoothened = smoothing; newArr = computeSpeed(filteredGPSArr); } if (!newArr.length) { console.error('Error intrerpreting metadata'); return null; } let stats = computeStats(newArr); return { packets: newArr, stats: stats }; }; DJI_SRT_Parser.prototype.setCustomProperties = function (customProps = false) { // If no value is passed, clean the properties if (!customProps) return (this.customProperties = {}); if (typeof customProps === 'object') return (this.customProperties = { ...this.customProperties, ...customProps }); return false; }; function getElevationKey(src) { //Elevation has different names on each format if (src.ALTITUDE != undefined) { return 'ALTITUDE'; } else if (src.BAROMETER != undefined) { return 'BAROMETER'; } else if (src.HB != undefined) { return 'HB'; } return 'ALTITUDE'; } function getElevation(src) { //Elevation has different names on each format if (src.ALTITUDE != undefined) { return src.ALTITUDE; } else if (src.BAROMETER != undefined) { return src.BAROMETER; } else if (src.HB != undefined) { return src.HB; } return null; } DJI_SRT_Parser.prototype.createCSV = function (raw) { let context = this; let columnHeads = [], rows = []; // Source: https://stackoverflow.com/questions/34513964/how-to-convert-this-nested-object-into-a-flat-object const flatObject = input => { function flat(res, key, val, pre = '') { const prefix = [pre, key].filter(v => v).join('.'); return val != null && typeof val === 'object' ? Object.keys(val).reduce( (prev, curr) => flat(prev, curr, val[curr], prefix), res ) : Object.assign(res, { [prefix]: val }); } return Object.keys(input).reduce( (prev, curr) => flat(prev, curr, input[curr]), {} ); }; const prepareColumnHeads = obj => { obj = flatObject(obj); // Flat arrays and objects obj = { ...obj, ...context.customProperties, ...{ NAME: 1 } }; columnHeads = Object.keys(obj); return columnHeads; }; const populateData = (array, fileName) => { let dataArr = []; array.forEach(arr => { let obj = flatObject(arr); let results = []; // Double quotes on every field. It's always neccesary? columnHeads.forEach(column => { if (obj.hasOwnProperty(column)) results.push(`"${obj[column]}"`); else if (context.customProperties[column]) // Custom properties results.push(`"${context.customProperties[column]}"`); else if ('NAME' === column) results.push(`"${cleanFileName(fileName)}"`); else results.push(' '); // Fill empty columns }); dataArr.push(results); }); return dataArr; }; if (context.isMultiple) { let array = raw ? Object.values(this.rawMetadata) : Object.keys(this.metadata).map(key => this.metadata[key].packets); // First search for all the different headers in all the files let mergedFirstElements; array.forEach(arr => { mergedFirstElements = { ...mergedFirstElements, ...arr[0] }; }); rows = [prepareColumnHeads(mergedFirstElements)]; array.forEach((arr, key) => { rows = [...rows, ...populateData(arr, context.fileName[key])]; }); } else { let array = raw ? this.rawMetadata : this.metadata.packets; rows = [prepareColumnHeads(array[0])]; rows = [...rows, ...populateData(array, context.fileName)]; } if (rows.length < 1) return null; let csvContent = rows.reduce((acc, rowArray) => { let row = rowArray.join(','); return acc + row + '\r\n'; }, ''); if (!csvContent) { console.error('Error creating CSV'); return null; } return csvContent; }; DJI_SRT_Parser.prototype.createMGJSON = function ( name = '', elevationOffset = 0 ) { let packets = this.isMultiple ? Object.keys(this.metadata) .map(key => this.metadata[key].packets) .reduce((acc, val) => acc.concat(val), []) // Merge everything : this.metadata.packets; name = this.isMultiple && Array.isArray(name) ? name.join('-') : name; const mgJSONContent = toMGJSON(packets, name, elevationOffset); return mgJSONContent; }; DJI_SRT_Parser.prototype.createGeoJSON = function ( raw, waypoints, elevationOffset = 0 ) { let context = this; function GeoJSONExtract(obj, raw) { let extractProps = function (childObj, pre) { let results = []; for (let child in childObj) { if (typeof childObj[child] === 'object' && childObj[child] != null) { let children = extractProps(childObj[child], pre + '_' + child); children.forEach(child => results.push(child)); } else { results.push({ name: pre + '_' + child, value: childObj[child] }); } } return results; }; let extractCoordinates = function (coordsObj) { let coordResult = []; if (raw) { if (coordsObj.GPS.length >= 0 && coordsObj.GPS[0]) coordResult[0] = coordsObj.GPS[0]; if (coordsObj.GPS.length >= 1 && coordsObj.GPS[1]) coordResult[1] = coordsObj.GPS[1]; } else { if (coordsObj.GPS.LONGITUDE) coordResult[0] = coordsObj.GPS.LONGITUDE; if (coordsObj.GPS.LATITUDE) coordResult[1] = coordsObj.GPS.LATITUDE; if (getElevation(coordsObj) != null) { coordResult[2] = getElevation(coordsObj) + elevationOffset; } } return coordResult; }; let result = { type: 'Feature', geometry: { type: 'Point', coordinates: [] }, properties: { ...context.customProperties } }; for (let elt in obj) { if (elt === 'DATE') { result.properties.timestamp = obj[elt]; } else if (elt === 'GPS') { result.geometry.coordinates = extractCoordinates(obj); } else if (typeof obj[elt] === 'object' && obj[elt] != null) { let children = extractProps(obj[elt], elt); children.forEach(child => { result.properties[child.name] = child.value; }); } else { result.properties[elt] = obj[elt]; } } return result; } let GeoJSONContent = { type: 'FeatureCollection', crs: { type: 'name', properties: { name: 'urn:ogc:def:crs:OGC:1.3:CRS84' } }, features: [] }; function preProcess(array, fileName) { let features = []; array.forEach(pck => features.push(GeoJSONExtract(pck, raw))); if (waypoints) { GeoJSONContent.features = [...GeoJSONContent.features, ...features]; } let createLinestring = function (features) { let result = { type: 'Feature', properties: { source: 'dji-srt-parser', timestamp: [], name: cleanFileName(fileName), ...context.customProperties }, geometry: { type: 'LineString', coordinates: [] } }; let props = features[0].properties; for (let prop in props) { if ( ![ 'DATE', 'TIMECODE', 'GPS', 'timestamp', 'BAROMETER', 'DISTANCE', 'SPEED_THREED', 'SPEED_TWOD', 'SPEED_VERTICAL', 'HB' ].includes(prop) ) { result.properties[prop] = props[prop]; } } features.forEach(feature => { result.geometry.coordinates.push(feature.geometry.coordinates); result.properties.timestamp.push(feature.properties.timestamp); }); return result; }; GeoJSONContent.features.push(createLinestring(features)); } if (context.isMultiple) { Object.keys(this.metadata).forEach(key => { if (raw) preProcess(this.rawMetadata[key], key); else preProcess(this.metadata[key].packets, key); }); } else { preProcess( raw ? this.rawMetadata : this.metadata.packets, context.fileName ); } if (!GeoJSONContent.features) { console.error('Error creating GeoJSON'); return null; } return JSON.stringify(GeoJSONContent); }; DJI_SRT_Parser.prototype.loadFile = function (data, fileName, isPreparedData) { let context = this; this.fileName = fileName; this.loaded = false; let decode = function (d) { if (typeof d === 'string' && d.split(',')[0].includes('base64')) { return atob(d.split(',')[1]); } else { return d; } }; let decoded; if (Array.isArray(data) && Array.isArray(fileName)) { context.isMultiple = true; decoded = data.map(d => decode(d)); } else { decoded = decode(data); } this.flow(decoded, isPreparedData); }; DJI_SRT_Parser.prototype.getMetadata = function (fileName = null) { if (fileName && this.isMultiple) return this.metadata[fileName]; return this.metadata; }; DJI_SRT_Parser.prototype.getRawMetadata = function (fileName = null) { if (fileName && this.isMultiple) return this.rawMetadata[fileName]; return this.rawMetadata; }; DJI_SRT_Parser.prototype.flow = function (data, isPreparedData) { this.rawMetadata = {}; let rawMetadata; const throwEmptyError = () => { throw 'Not valid data'; }; const maybeParse = data => { try { JSON.parse(data); } catch (e) { return data; } return JSON.parse(data); }; const getRaw = data => { return isPreparedData ? maybeParse(data) : this.srtToObject(data); }; if (this.isMultiple) { data.forEach((d, key) => { rawMetadata = getRaw(d); if (rawMetadata) { let fileName = this.fileName[key]; this.rawMetadata[fileName] = rawMetadata; this.metadata[fileName] = this.interpretMetadata(rawMetadata); } }); // If no data, return null if (!Object.keys(this.rawMetadata).length) throwEmptyError(); } else { rawMetadata = getRaw(data); if (rawMetadata) { this.rawMetadata = rawMetadata; this.metadata = this.interpretMetadata(this.rawMetadata); } else { // If no data, return null throwEmptyError(); } } this.loaded = true; }; function cleanFileName(fileName) { return fileName.replace(/\.[^/.]+$/, ''); // Remove file extension } function notReady() { console.error('Data not ready'); return null; } function isNum(val) { return /^[-\+\d.,]+$/.test(val); } function toExport(context, file, fileName, isPreparedData) { context.loadFile(file, fileName, isPreparedData); return { getSmoothing: function () { return context.loaded ? context.smoothened : notReady(); }, getMillisecondsPerSample: function () { return context.loaded ? context.millisecondsSample : notReady(); }, setSmoothing: function (smooth) { if (!context.loaded) return notReady(); if (context.isMultiple) { Object.keys(context.rawMetadata).forEach(key => { context.metadata[key] = context.interpretMetadata( context.rawMetadata[key], smooth ); }); return context.metadata; } return (context.metadata = context.interpretMetadata( context.rawMetadata, smooth )); }, setMillisecondsPerSample: function (milliseconds) { if (!context.loaded) return notReady(); if (context.isMultiple) { Object.keys(context.metadata).forEach(key => { context.metadata[key].packets = context.millisecondsPerSample( context.metadata[key], milliseconds ); }); return context.metadata.packets; } return (context.metadata.packets = context.millisecondsPerSample( context.metadata, milliseconds )); }, rawMetadata: function (fileName) { return context.loaded ? context.getRawMetadata(fileName) : notReady(); }, metadata: function (fileName) { return context.loaded ? context.getMetadata(fileName) : notReady(); }, toCSV: function (raw) { return context.loaded ? context.createCSV(raw) : notReady(); }, toMGJSON: function (elevationOffset) { return context.loaded ? context.createMGJSON(context.fileName, elevationOffset) : notReady(); }, toGeoJSON: function (raw, waypoints, elevationOffset) { return context.loaded ? context.createGeoJSON(raw, waypoints, elevationOffset) : notReady(); }, getFileName: function () { return context.loaded ? context.fileName : notReady(); }, setProperties: function (customProps) { return context.loaded ? context.setCustomProperties(customProps) : notReady(); } }; } /** * @param {string | array} file - The loaded data in a string (or array of strings) * @param {string | array} fileName - The fileName/s in a string (or array of strings) * @param {boolean} [isPreparedData] - It's a custom GeoJSON that was passed in file? */ function create_DJI_SRT_Parser(file, fileName, isPreparedData) { try { const instance = new DJI_SRT_Parser(); return toExport(instance, file, fileName, isPreparedData); } catch (err) { console.error(err); return null; } } module.exports = create_DJI_SRT_Parser;