From f2ecc1803f3ea294a0c6b7915b61348ed0395b26 Mon Sep 17 00:00:00 2001
From: Marcin Zelent <marcin@zelent.net>
Date: Wed, 16 Nov 2022 15:16:38 +0100
Subject: Remade and extended the app using React

---
 src/generate-data.ts | 563 +++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 563 insertions(+)
 create mode 100644 src/generate-data.ts

(limited to 'src/generate-data.ts')

diff --git a/src/generate-data.ts b/src/generate-data.ts
new file mode 100644
index 0000000..a67c620
--- /dev/null
+++ b/src/generate-data.ts
@@ -0,0 +1,563 @@
+import { constants as fsConstants, Dirent } from 'fs';
+import { access, mkdir, readdir, readFile, writeFile } from 'fs/promises';
+import { promisify } from 'util';
+import path from 'path';
+import exifReader from 'exif-reader';
+import sizeOf from 'image-size';
+import sharp from 'sharp';
+import ffmpegPath from 'ffmpeg-static';
+import ffprobePath from 'ffprobe-static';
+import FfmpegCommand, { FfprobeData } from 'fluent-ffmpeg';
+import { DOMParser } from 'xmldom';
+import { FeatureCollection, LineString } from 'geojson';
+import { gpx } from '@tmcw/togeojson';
+import gb from 'geojson-bounds';
+import { MediaItem, Group, MediaType } from './models';
+import { distanceBetween, secondsToTimeString } from './lib/util';
+
+const promisifiedSizeOf = promisify(sizeOf);
+
+interface GroupedFiles {
+  photos: Dirent[];
+  videos: Dirent[];
+  geoData: Dirent[];
+}
+
+interface DimensionsTuple {
+  width: number;
+  height: number;
+}
+
+interface FileError {
+  file: string;
+  error: any;
+}
+
+const failedFiles: FileError[] = [];
+
+function setupFfmpeg(): 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);
+}
+
+function hasExtension(filename: string, acceptedExtensions: string[]): boolean {
+  const index = filename.lastIndexOf('.');
+  if (index < 0) {
+    return false;
+  }
+
+  const extension = filename.substring(index + 1);
+
+  return acceptedExtensions.includes(extension);
+}
+
+function hasImageExtension(filename: string): boolean {
+  const acceptedExtensions = [
+    'apng',
+    'avif',
+    'bmp',
+    'dib',
+    'gif',
+    'jfi',
+    'jfif',
+    'jif',
+    'jpe',
+    'jpeg',
+    'jpg',
+    'png',
+    'tif',
+    'tiff',
+    'webp',
+  ];
+
+  return hasExtension(filename, acceptedExtensions);
+}
+
+function hasVideoExtension(filename: string): boolean {
+  const acceptedExtensions = ['avi', 'mkv', 'mov', 'mp4', 'webm'];
+
+  return hasExtension(filename, acceptedExtensions);
+}
+
+function convertCoordinate(coordinate: number[], coordinateRef: string): number | undefined {
+  if (coordinate !== undefined && coordinateRef !== undefined) {
+    const ref = coordinateRef === 'N' || coordinateRef === 'E' ? 1 : -1;
+    return ref * (coordinate[0] + coordinate[1] / 60 + coordinate[2] / 3600);
+  }
+}
+
+async function checkIfFileExists(filepath: string): Promise<boolean> {
+  try {
+    await access(filepath, fsConstants.F_OK);
+    return true;
+  } catch (err: any) {
+    if (err.code === 'ENOENT') {
+      return false;
+    }
+
+    throw err;
+  }
+}
+
+function getNormalImageSize({
+  width,
+  height,
+  orientation,
+}: {
+  width: number;
+  height: number;
+  orientation: number;
+}): DimensionsTuple {
+  return orientation >= 5 ? { width: height, height: width } : { width, height };
+}
+
+async function resizePhoto(
+  photo: sharp.Sharp,
+  filepath: string,
+  orientation: number,
+): Promise<DimensionsTuple> {
+  const outputInfo = await photo
+    .withMetadata({ orientation })
+    .resize({
+      width: orientation >= 5 ? 1080 : undefined,
+      height: 1080,
+      fit: sharp.fit.inside,
+      withoutEnlargement: true,
+    })
+    .toFile(filepath);
+
+  return getNormalImageSize({ ...outputInfo, orientation });
+}
+
+async function resizePhotoOrGetSize(
+  photo: sharp.Sharp,
+  filepath: string,
+  orientation: number,
+): Promise<DimensionsTuple> {
+  const absolutePath = path.resolve(filepath);
+  const fileExists = await checkIfFileExists(absolutePath);
+
+  if (!fileExists) {
+    console.log(`File "${absolutePath}" doesn't exist, resizing the image...`);
+    return await resizePhoto(photo, absolutePath, orientation);
+  }
+
+  console.log(`File ${absolutePath} already exists, trying to get dimensions of the image...`);
+
+  try {
+    const dimensions = await promisifiedSizeOf(absolutePath);
+    if (dimensions?.width !== undefined || dimensions?.height !== undefined) {
+      return getNormalImageSize({
+        width: dimensions.width as number,
+        height: dimensions.height as number,
+        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}...`);
+  return await resizePhoto(photo, absolutePath, orientation);
+}
+
+async function generateThumbnail(
+  photo: sharp.Sharp,
+  filepath: string,
+  orientation?: number,
+): Promise<void> {
+  const fileExists = await checkIfFileExists(filepath);
+  if (!fileExists) {
+    await photo
+      .withMetadata({ orientation })
+      .resize({ width: 36, height: 36, withoutEnlargement: true })
+      .toFile(filepath);
+  }
+}
+
+async function getPhotos(
+  inputDir: string,
+  outputPath: string,
+  serverPath: string,
+  relativePath: string,
+  files: Dirent[],
+): Promise<MediaItem[]> {
+  const photos: MediaItem[] = [];
+
+  for (const file of files) {
+    const inputFile = `${inputDir}/${file.name}`;
+    const outputDir = `${outputPath}/${relativePath}`;
+
+    try {
+      // load photo using sharp
+      const photo = sharp(inputFile, { failOnError: false });
+
+      // read photo metadata
+      const metadata = await photo.metadata();
+      const exifData = exifReader(metadata.exif);
+
+      // reformat EXIF coordinates to usable ones
+      const latitude = convertCoordinate(exifData.gps?.GPSLatitude, exifData.gps?.GPSLatitudeRef);
+      const longitude = convertCoordinate(
+        exifData.gps?.GPSLongitude,
+        exifData.gps?.GPSLongitudeRef,
+      );
+
+      await mkdir(outputDir, { recursive: true });
+
+      // resize the photo or get size of existing
+      const outputInfo = await resizePhotoOrGetSize(
+        photo,
+        `${outputDir}/${file.name}`,
+        metadata.orientation ?? 1,
+      );
+
+      await generateThumbnail(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 });
+    }
+  }
+
+  return photos;
+}
+
+async function getVideoInfo(file: string): Promise<FfprobeData> {
+  return await new Promise((resolve, reject) => {
+    FfmpegCommand.ffprobe(file, (err, data) => {
+      if (err !== undefined && err !== null) {
+        return reject(err);
+      }
+
+      return resolve(data);
+    });
+  });
+}
+
+async function optimizeVideo(inputFile: string, outputFile: string): Promise<FfprobeData> {
+  const command = FfmpegCommand(inputFile);
+
+  return await new Promise((resolve, reject) => {
+    command
+      .complexFilter(["scale=-2:min'(1080,ih)'"])
+      .on('progress', (progress) => {
+        console.log(`Processing file ${inputFile}: ${progress.percent as number}% done`);
+      })
+      .on('error', (err) => reject(err))
+      .on('end', () => resolve(getVideoInfo(outputFile)))
+      .save(outputFile);
+  });
+}
+
+async function generateVideoThumbnail(
+  inputFile: string,
+  outputDir: string,
+  outputFileName: string,
+): Promise<void> {
+  const command = FfmpegCommand(inputFile);
+
+  return await new Promise((resolve, reject) => {
+    command
+      .screenshots({ size: '36x36', count: 1, folder: outputDir, filename: outputFileName })
+      .on('error', (err) => reject(err))
+      .on('end', () => resolve());
+  });
+}
+
+async function optimizeVideoOrGetInfo(inputFile: string, outputFile: string): Promise<FfprobeData> {
+  const fileExists = await checkIfFileExists(outputFile);
+
+  if (!fileExists) {
+    console.log(`File "${outputFile}" doesn't exist, optimizing the video...`);
+    return await optimizeVideo(inputFile, outputFile);
+  }
+
+  console.log(`File ${outputFile} already exists, trying to get info about the video...`);
+
+  try {
+    return await getVideoInfo(outputFile);
+  } catch (err) {
+    console.error(`Could not get the info about video ${outputFile}.`);
+    console.error(err);
+  }
+
+  return await optimizeVideo(inputFile, outputFile);
+}
+
+async function processVideo(
+  inputDir: string,
+  outputPath: string,
+  serverPath: string,
+  relativePath: string,
+  file: Dirent,
+): Promise<MediaItem> {
+  const inputFile = `${inputDir}/${file.name}`;
+  const outputDir = `${outputPath}/${relativePath}`;
+  const outputFileName = `${file.name.substring(0, file.name.lastIndexOf('.'))}.webm`;
+  const outputFile = path.resolve(`${outputDir}/${outputFileName}`);
+
+  try {
+    await mkdir(outputDir, { recursive: true });
+
+    // re-encode video or get info about the existing one
+    const info = await optimizeVideoOrGetInfo(inputFile, outputFile);
+    const videoStream = info.streams.find((s) => s.codec_type === 'video');
+
+    // generate thumbnail
+    const thumbnailFileName = `thumb-${file.name.substring(0, file.name.lastIndexOf('.'))}.jpg`;
+    const thumbnailExists = await checkIfFileExists(`${outputDir}/${thumbnailFileName}`);
+    if (!thumbnailExists) {
+      await generateVideoThumbnail(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 });
+    throw err;
+  }
+}
+
+async function getVideos(
+  inputDir: string,
+  outputPath: string,
+  serverPath: string,
+  relativePath: string,
+  files: Dirent[],
+): Promise<MediaItem[]> {
+  const videos: MediaItem[] = [];
+
+  for (const file of files) {
+    const video = await processVideo(inputDir, outputPath, serverPath, relativePath, file);
+    videos.push(video);
+  }
+
+  return videos;
+}
+
+async function getMedia(
+  inputDir: string,
+  outputPath: string,
+  serverPath: string,
+  relativePath: string,
+  files: GroupedFiles,
+): Promise<MediaItem[]> {
+  const photos = await getPhotos(inputDir, outputPath, serverPath, relativePath, files.photos);
+  const videos = await getVideos(inputDir, outputPath, serverPath, relativePath, files.videos);
+  const media = photos.concat(videos);
+
+  return media;
+}
+
+async function getFiles(inputDir: string): Promise<GroupedFiles> {
+  console.log(`Processing files in ${inputDir} directory...`);
+
+  const dirents = await readdir(inputDir, { withFileTypes: true });
+  const files = dirents.reduce<GroupedFiles>(
+    (prev, dirent) => {
+      if (!dirent.isFile()) {
+        return prev;
+      }
+
+      if (hasImageExtension(dirent.name)) {
+        prev.photos.push(dirent);
+      } else if (hasVideoExtension(dirent.name)) {
+        prev.videos.push(dirent);
+      } else if (hasExtension(dirent.name, ['gpx'])) {
+        prev.geoData.push(dirent);
+      }
+
+      return prev;
+    },
+    { photos: [], videos: [], geoData: [] },
+  );
+
+  return files;
+}
+
+async function processGeoData(inputDir: string, files: Dirent[]): Promise<FeatureCollection[]> {
+  const geoData: FeatureCollection[] = [];
+
+  for (const file of files) {
+    // read file contents
+    const fileContents = await readFile(`${inputDir}/${file.name}`, 'utf8');
+    const contentsWithoutNS = fileContents.replace(/\sxmlns[^"]+"[^"]+"/g, '');
+
+    // create DOM from string
+    const doc = new DOMParser().parseFromString(contentsWithoutNS, 'text/xml');
+
+    // convert GPX to GeoJSON
+    const track: FeatureCollection = gpx(doc);
+
+    // add bounding box
+    track.bbox = [gb.xMin(track), gb.yMin(track), gb.xMax(track), gb.yMax(track)];
+
+    geoData.push(track);
+  }
+
+  return geoData;
+}
+
+function createMetadataFromGeoJSON(
+  geoData: FeatureCollection,
+): { [key: string]: string | number } | undefined {
+  if (geoData === undefined) {
+    return;
+  }
+
+  const coordTimes = geoData.features[0].properties?.coordinateProperties?.times;
+
+  if (coordTimes === undefined || coordTimes?.length === 0) {
+    return;
+  }
+
+  // time of the first point
+  const start = coordTimes[0];
+
+  // time of the last point
+  const end = coordTimes[coordTimes.length - 1];
+
+  // total duration in seconds
+  const duration = (new Date(end).getTime() - new Date(start).getTime()) / 1000;
+  const durationString = secondsToTimeString(duration);
+
+  // distance and speed
+  let totalDistance = 0;
+  const speeds = [];
+  const coords = (geoData.features[0].geometry as LineString).coordinates;
+
+  for (let i = 0; i < coords.length - 1; i += 1) {
+    const a = [coords[i][1], coords[i][0]];
+    const b = [coords[i + 1][1], coords[i + 1][0]];
+    const distance = distanceBetween(a, b);
+    if (distance > 0) {
+      totalDistance += distance;
+      const timeBetween = new Date(coordTimes[i + 1]).getTime() - new Date(coordTimes[i]).getTime();
+      speeds.push(distance / timeBetween);
+    }
+  }
+
+  // total distance in km
+  const distance = Math.floor(totalDistance / 10) / 100;
+
+  // average speed in km/h
+  const speedMps = speeds.reduce((acc, val) => acc + val) / speeds.length;
+  const speed = Math.floor(speedMps * 3600 * 100) / 100;
+
+  return { duration: durationString, distance, speed };
+}
+
+async function getGroups(
+  inputPath: string,
+  outputPath: string,
+  serverPath: string,
+  relativePath: string,
+): Promise<Group[]> {
+  const dirs = (await readdir(inputPath, { withFileTypes: true })).filter((dirent) =>
+    dirent.isDirectory(),
+  );
+
+  const groups: Group[] = [];
+
+  for (const dir of dirs) {
+    const name = dir.name;
+    const id = name.replaceAll(' ', '-').toLowerCase();
+
+    const inputDir = `${inputPath}/${name}`;
+    const relativeGroupPath = `${relativePath}/${id}`;
+
+    const files = await getFiles(inputDir);
+
+    const media = await getMedia(inputDir, outputPath, serverPath, relativeGroupPath, files);
+    media.sort((a, b) => (a.name > b.name ? 1 : -1));
+
+    const geoData = await processGeoData(inputDir, files.geoData);
+
+    const metadata = createMetadataFromGeoJSON(geoData[0]);
+
+    const group: 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);
+  }
+
+  return groups;
+}
+
+async function main(): 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();
+
+  const inputPath = process.argv[2];
+  const outputPath = process.argv[3];
+  const serverPath = process.argv[4] ?? '';
+
+  const name = path.basename(inputPath);
+  const id = name.replaceAll(' ', '-').toLowerCase();
+  const groups = await getGroups(inputPath, outputPath, serverPath, id);
+
+  const trip = { id, name, groups };
+  const tripJson = JSON.stringify(trip);
+
+  const outputDir = `${outputPath}/${id}`;
+  const outputFile = `${outputDir}/index.json`;
+
+  console.log(`Writing output to ${outputFile}...`);
+
+  try {
+    await mkdir(outputDir, { recursive: true });
+    await writeFile(outputFile, tripJson);
+  } catch (err) {
+    console.error(err);
+  }
+
+  if (failedFiles.length > 0) {
+    console.error(
+      `Failed to process:\n${failedFiles
+        .map((f) => `- ${f.file}\n  ${f.error as string}`)
+        .join('\n')}`,
+    );
+  }
+}
+
+void main();
-- 
cgit v1.2.3