import{constantsasfsConstants,Dirent}from'fs';import{access,mkdir,readdir,readFile,writeFile}from'fs/promises';import{promisify}from'util';importpathfrom'path';importexifReaderfrom'exif-reader';importsizeOffrom'image-size';importsharpfrom'sharp';importffmpegPathfrom'ffmpeg-static';importffprobePathfrom'ffprobe-static';importFfmpegCommand,{FfprobeData}from'fluent-ffmpeg';import{DOMParser}from'xmldom';import{FeatureCollection,LineString}from'geojson';import{gpx}from'@tmcw/togeojson';importgbfrom'geojson-bounds';import{MediaItem,Group,MediaType}from'./models';import{distanceBetween,secondsToTimeString}from'./lib/util';constpromisifiedSizeOf=promisify(sizeOf);interfaceGroupedFiles{photos:Dirent[];videos:Dirent[];geoData:Dirent[];}interfaceDimensionsTuple{width:number;height:number;}interfaceFileError{file:string;error:any;}constfailedFiles:FileError[]=[];functionsetupFfmpeg():void{if(ffmpegPath===null){console.warn('Could not use built-in FFmpeg. Make sure you have FFmpeg installed, if you want to process videos.',);return;}FfmpegCommand.setFfmpegPath(ffmpegPath);FfmpegCommand.setFfprobePath(ffprobePath.path);}functionhasExtension(filename:string,acceptedExtensions:string[]):boolean{constindex=filename.lastIndexOf('.');if(index<0){returnfalse;}constextension=filename.substring(index+1);returnacceptedExtensions.includes(extension);}functionhasImageExtension(filename:string):boolean{constacceptedExtensions=['apng','avif','bmp','dib','gif','jfi','jfif','jif','jpe','jpeg','jpg','png','tif','tiff','webp',];returnhasExtension(filename,acceptedExtensions);}functionhasVideoExtension(filename:string):boolean{constacceptedExtensions=['avi','mkv','mov','mp4','webm'];returnhasExtension(filename,acceptedExtensions);}functionconvertCoordinate(coordinate:number[],coordinateRef:string):number|undefined{if(coordinate!==undefined&&coordinateRef!==undefined){constref=coordinateRef==='N'||coordinateRef==='E'?1:-1;returnref*(coordinate[0]+coordinate[1]/60+coordinate[2]/3600);}}asyncfunctioncheckIfFileExists(filepath:string):Promise<boolean>{try{awaitaccess(filepath,fsConstants.F_OK);returntrue;}catch(err:any){if(err.code==='ENOENT'){returnfalse;}throwerr;}}functiongetNormalImageSize({width,height,orientation,}:{width:number;height:number;orientation:number;}):DimensionsTuple{returnorientation>=5?{width:height,height:width}:{width,height};}asyncfunctionresizePhoto(photo:sharp.Sharp,filepath:string,orientation:number,):Promise<DimensionsTuple>{constoutputInfo=awaitphoto.withMetadata({orientation}).resize({width:orientation>=5?1080:undefined,height:1080,fit:sharp.fit.inside,withoutEnlargement:true,}).toFile(filepath);returngetNormalImageSize({...outputInfo,orientation});}asyncfunctionresizePhotoOrGetSize(photo:sharp.Sharp,filepath:string,orientation:number,):Promise<DimensionsTuple>{constabsolutePath=path.resolve(filepath);constfileExists=awaitcheckIfFileExists(absolutePath);if(!fileExists){console.log(`File "${absolutePath}" doesn't exist, resizing the image...`);returnawaitresizePhoto(photo,absolutePath,orientation);}console.log(`File ${absolutePath} already exists, trying to get dimensions of the image...`);try{constdimensions=awaitpromisifiedSizeOf(absolutePath);if(dimensions?.width!==undefined||dimensions?.height!==undefined){returngetNormalImageSize({width:dimensions.widthasnumber,height:dimensions.heightasnumber,orientation,});}}catch(err){console.error(`Could not get the dimensions of the image ${absolutePath}.`);console.error(err);}console.log(`Trying to resize the image ${absolutePath}...`);returnawaitresizePhoto(photo,absolutePath,orientation);}asyncfunctiongenerateThumbnail(photo:sharp.Sharp,filepath:string,orientation?:number,):Promise<void>{constfileExists=awaitcheckIfFileExists(filepath);if(!fileExists){awaitphoto.withMetadata({orientation}).resize({width:36,height:36,withoutEnlargement:true}).toFile(filepath);}}asyncfunctiongetPhotos(inputDir:string,outputPath:string,serverPath:string,relativePath:string,files:Dirent[],):Promise<MediaItem[]>{constphotos:MediaItem[]=[];for(constfileoffiles){constinputFile=`${inputDir}/${file.name}`;constoutputDir=`${outputPath}/${relativePath}`;try{// load photo using sharpconstphoto=sharp(inputFile,{failOnError:false});// read photo metadataconstmetadata=awaitphoto.metadata();constexifData=exifReader(metadata.exif);// reformat EXIF coordinates to usable onesconstlatitude=convertCoordinate(exifData.gps?.GPSLatitude,exifData.gps?.GPSLatitudeRef);constlongitude=convertCoordinate(exifData.gps?.GPSLongitude,exifData.gps?.GPSLongitudeRef,);awaitmkdir(outputDir,{recursive:true});// resize the photo or get size of existingconstoutputInfo=awaitresizePhotoOrGetSize(photo,`${outputDir}/${file.name}`,metadata.orientation??1,);awaitgenerateThumbnail(photo,`${outputDir}/thumb-${file.name}`,metadata.orientation);photos.push({name:file.name,src:`${serverPath}/${relativePath}/${file.name}`,type:MediaType.Photo,width:outputInfo.width,height:outputInfo.height,latitude,longitude,time:exifData.exif?.DateTimeOriginal,thumbnail:`${serverPath}/${relativePath}/thumb-${file.name}`,caption:exifData.image?.ImageDescription,});}catch(err){console.error(`Could not process the file ${inputFile}.`);console.error(err);failedFiles.push({file:inputFile,error:err});}}returnphotos;}asyncfunctiongetVideoInfo(file:string):Promise<FfprobeData>{returnawaitnewPromise((resolve,reject)=>{FfmpegCommand.ffprobe(file,(err,data)=>{if(err!==undefined&&err!==null){returnreject(err);}returnresolve(data);});});}asyncfunctionoptimizeVideo(inputFile:string,outputFile:string):Promise<FfprobeData>{constcommand=FfmpegCommand(inputFile);returnawaitnewPromise((resolve,reject)=>{command.complexFilter(["scale=-2:min'(1080,ih)'"]).on('progress',(progress)=>{console.log(`Processing file ${inputFile}: ${progress.percentasnumber}% done`);}).on('error',(err)=>reject(err)).on('end',()=>resolve(getVideoInfo(outputFile))).save(outputFile);});}asyncfunctiongenerateVideoThumbnail(inputFile:string,outputDir:string,outputFileName:string,):Promise<void>{constcommand=FfmpegCommand(inputFile);returnawaitnewPromise((resolve,reject)=>{command.screenshots({size:'36x36',count:1,folder:outputDir,filename:outputFileName}).on('error',(err)=>reject(err)).on('end',()=>resolve());});}asyncfunctionoptimizeVideoOrGetInfo(inputFile:string,outputFile:string):Promise<FfprobeData>{constfileExists=awaitcheckIfFileExists(outputFile);if(!fileExists){console.log(`File "${outputFile}" doesn't exist, optimizing the video...`);returnawaitoptimizeVideo(inputFile,outputFile);}console.log(`File ${outputFile} already exists, trying to get info about the video...`);try{returnawaitgetVideoInfo(outputFile);}catch(err){console.error(`Could not get the info about video ${outputFile}.`);console.error(err);}returnawaitoptimizeVideo(inputFile,outputFile);}asyncfunctionprocessVideo(inputDir:string,outputPath:string,serverPath:string,relativePath:string,file:Dirent,):Promise<MediaItem>{constinputFile=`${inputDir}/${file.name}`;constoutputDir=`${outputPath}/${relativePath}`;constoutputFileName=`${file.name.substring(0,file.name.lastIndexOf('.'))}.webm`;constoutputFile=path.resolve(`${outputDir}/${outputFileName}`);try{awaitmkdir(outputDir,{recursive:true});// re-encode video or get info about the existing oneconstinfo=awaitoptimizeVideoOrGetInfo(inputFile,outputFile);constvideoStream=info.streams.find((s)=>s.codec_type==='video');// generate thumbnailconstthumbnailFileName=`thumb-${file.name.substring(0,file.name.lastIndexOf('.'))}.jpg`;constthumbnailExists=awaitcheckIfFileExists(`${outputDir}/${thumbnailFileName}`);if(!thumbnailExists){awaitgenerateVideoThumbnail(inputFile,outputDir,thumbnailFileName);}return{name:outputFileName,src:`${serverPath}/${relativePath}/${outputFileName}`,type:MediaType.Video,width:videoStream?.width??0,height:videoStream?.height??0,thumbnail:`${serverPath}/${relativePath}/${thumbnailFileName}`,};}catch(err){console.error(`Could not process the file ${inputFile}.`);console.error(err);failedFiles.push({file:inputFile,error:err});throwerr;}}asyncfunctiongetVideos(inputDir:string,outputPath:string,serverPath:string,relativePath:string,files:Dirent[],):Promise<MediaItem[]>{constvideos:MediaItem[]=[];for(constfileoffiles){constvideo=awaitprocessVideo(inputDir,outputPath,serverPath,relativePath,file);videos.push(video);}returnvideos;}asyncfunctiongetMedia(inputDir:string,outputPath:string,serverPath:string,relativePath:string,files:GroupedFiles,):Promise<MediaItem[]>{constphotos=awaitgetPhotos(inputDir,outputPath,serverPath,relativePath,files.photos);constvideos=awaitgetVideos(inputDir,outputPath,serverPath,relativePath,files.videos);constmedia=photos.concat(videos);returnmedia;}asyncfunctiongetFiles(inputDir:string):Promise<GroupedFiles>{console.log(`Processing files in ${inputDir} directory...`);constdirents=awaitreaddir(inputDir,{withFileTypes:true});constfiles=dirents.reduce<GroupedFiles>((prev,dirent)=>{if(!dirent.isFile()){returnprev;}if(hasImageExtension(dirent.name)){prev.photos.push(dirent);}elseif(hasVideoExtension(dirent.name)){prev.videos.push(dirent);}elseif(hasExtension(dirent.name,['gpx'])){prev.geoData.push(dirent);}returnprev;},{photos:[],videos:[],geoData:[]},);returnfiles;}asyncfunctionprocessGeoData(inputDir:string,files:Dirent[]):Promise<FeatureCollection[]>{constgeoData:FeatureCollection[]=[];for(constfileoffiles){// read file contentsconstfileContents=awaitreadFile(`${inputDir}/${file.name}`,'utf8');constcontentsWithoutNS=fileContents.replace(/\sxmlns[^"]+"[^"]+"/g,'');// create DOM from stringconstdoc=newDOMParser().parseFromString(contentsWithoutNS,'text/xml');// convert GPX to GeoJSONconsttrack:FeatureCollection=gpx(doc);// add bounding boxtrack.bbox=[gb.xMin(track),gb.yMin(track),gb.xMax(track),gb.yMax(track)];geoData.push(track);}returngeoData;}functioncreateMetadataFromGeoJSON(geoData:FeatureCollection,):{[key:string]:string|number}|undefined{if(geoData===undefined){return;}constcoordTimes=geoData.features[0].properties?.coordinateProperties?.times;if(coordTimes===undefined||coordTimes?.length===0){return;}// time of the first pointconststart=coordTimes[0];// time of the last pointconstend=coordTimes[coordTimes.length-1];// total duration in secondsconstduration=(newDate(end).getTime()-newDate(start).getTime())/1000;constdurationString=secondsToTimeString(duration);// distance and speedlettotalDistance=0;constspeeds=[];constcoords=(geoData.features[0].geometryasLineString).coordinates;for(leti=0;i<coords.length-1;i+=1){consta=[coords[i][1],coords[i][0]];constb=[coords[i+1][1],coords[i+1][0]];constdistance=distanceBetween(a,b);if(distance>0){totalDistance+=distance;consttimeBetween=newDate(coordTimes[i+1]).getTime()-newDate(coordTimes[i]).getTime();speeds.push(distance/timeBetween);}}// total distance in kmconstdistance=Math.floor(totalDistance/10)/100;// average speed in km/hconstspeedMps=speeds.reduce((acc,val)=>acc+val)/speeds.length;constspeed=Math.floor(speedMps*3600*100)/100;return{duration:durationString,distance,speed};}asyncfunctiongetGroups(inputPath:string,outputPath:string,serverPath:string,relativePath:string,):Promise<Group[]>{constdirs=(awaitreaddir(inputPath,{withFileTypes:true})).filter((dirent)=>dirent.isDirectory(),);constgroups:Group[]=[];for(constdirofdirs){constname=dir.name;constid=name.replaceAll(' ','-').toLowerCase();constinputDir=`${inputPath}/${name}`;constrelativeGroupPath=`${relativePath}/${id}`;constfiles=awaitgetFiles(inputDir);constmedia=awaitgetMedia(inputDir,outputPath,serverPath,relativeGroupPath,files);media.sort((a,b)=>(a.name>b.name?1:-1));constgeoData=awaitprocessGeoData(inputDir,files.geoData);constmetadata=createMetadataFromGeoJSON(geoData[0]);constgroup:Group={id,name,media,geoData,metadata};if(metadata!==undefined){group.description='Total distance: {distance} km\nDuration: {duration}\nAverage speed: {speed} km/h';}groups.push(group);}returngroups;}asyncfunctionmain():Promise<void>{if(process.argv[2]===undefined||process.argv[3]===undefined){console.log('Usage: npx ts-node ./src/generate-data.ts [INPUT_PATH] [OUTPUT_PATH] [SERVER_PATH]',);return;}setupFfmpeg();constinputPath=process.argv[2];constoutputPath=process.argv[3];constserverPath=process.argv[4]??'';constname=path.basename(inputPath);constid=name.replaceAll(' ','-').toLowerCase();constgroups=awaitgetGroups(inputPath,outputPath,serverPath,id);consttrip={id,name,groups};consttripJson=JSON.stringify(trip);constoutputDir=`${outputPath}/${id}`;constoutputFile=`${outputDir}/index.json`;console.log(`Writing output to ${outputFile}...`);try{awaitmkdir(outputDir,{recursive:true});awaitwriteFile(outputFile,tripJson);}catch(err){console.error(err);}if(failedFiles.length>0){console.error(`Failed to process:\n${failedFiles.map((f)=>`- ${f.file}\n ${f.errorasstring}`).join('\n')}`,);}}voidmain();