From d8c947a3e14ae23fe5f823c0ba08c147e0f19a91 Mon Sep 17 00:00:00 2001
From: Janne Mareike Koschinski <janne@kuschku.de>
Date: Sat, 31 Jul 2021 19:15:29 +0200
Subject: [PATCH] Implement basic EXIF support

---
 api/image_get.go                        |   5 +
 api/image_list.go                       |   5 +
 docs/EXIF.md                            | 119 ++++++++++++++
 model/image.go                          |  21 +--
 task/image_resize_processor.go          |  33 +++-
 ui/src/api/model/Image.ts               |   3 +
 ui/src/components/ImageView.tsx         |  51 +++++-
 ui/src/metadata/ContrastProcessing.ts   |   5 +
 ui/src/metadata/ExposureMode.ts         |   5 +
 ui/src/metadata/ExposureProgram.ts      |  10 ++
 ui/src/metadata/Flash.ts                |  12 ++
 ui/src/metadata/FlashMode.ts            |   5 +
 ui/src/metadata/ImageMetadata.ts        | 204 ++++++++++++++++++++++++
 ui/src/metadata/LightSource.ts          |  21 +++
 ui/src/metadata/MeteringMode.ts         |   8 +
 ui/src/metadata/Ratio.ts                |  33 ++++
 ui/src/metadata/SceneMode.ts            |   6 +
 ui/src/metadata/SharpnessProcessing.ts  |   5 +
 ui/src/metadata/SubjectDistanceRange.ts |   5 +
 ui/src/metadata/WhiteBalance.ts         |   4 +
 20 files changed, 548 insertions(+), 12 deletions(-)
 create mode 100644 docs/EXIF.md
 create mode 100644 ui/src/metadata/ContrastProcessing.ts
 create mode 100644 ui/src/metadata/ExposureMode.ts
 create mode 100644 ui/src/metadata/ExposureProgram.ts
 create mode 100644 ui/src/metadata/Flash.ts
 create mode 100644 ui/src/metadata/FlashMode.ts
 create mode 100644 ui/src/metadata/ImageMetadata.ts
 create mode 100644 ui/src/metadata/LightSource.ts
 create mode 100644 ui/src/metadata/MeteringMode.ts
 create mode 100644 ui/src/metadata/Ratio.ts
 create mode 100644 ui/src/metadata/SceneMode.ts
 create mode 100644 ui/src/metadata/SharpnessProcessing.ts
 create mode 100644 ui/src/metadata/SubjectDistanceRange.ts
 create mode 100644 ui/src/metadata/WhiteBalance.ts

diff --git a/api/image_get.go b/api/image_get.go
index d1ff4d2..eefc6fd 100644
--- a/api/image_get.go
+++ b/api/image_get.go
@@ -26,6 +26,11 @@ func GetImage(env environment.FrontendEnvironment) http.Handler {
 			http.Error(writer, err.Error(), http.StatusInternalServerError)
 			return
 		}
+		image.Metadata, err = env.Repositories.ImageMetadata.List(image)
+		if err != nil {
+			http.Error(writer, err.Error(), http.StatusInternalServerError)
+			return
+		}
 
 		util.ReturnJson(writer, image)
 	})
diff --git a/api/image_list.go b/api/image_list.go
index eef39a5..755aa9c 100644
--- a/api/image_list.go
+++ b/api/image_list.go
@@ -20,6 +20,11 @@ func ListImages(env environment.FrontendEnvironment) http.Handler {
 				http.Error(writer, err.Error(), http.StatusInternalServerError)
 				return
 			}
+			image.Metadata, err = env.Repositories.ImageMetadata.List(image)
+			if err != nil {
+				http.Error(writer, err.Error(), http.StatusInternalServerError)
+				return
+			}
 			images[idx] = image
 		}
 
diff --git a/docs/EXIF.md b/docs/EXIF.md
new file mode 100644
index 0000000..768be21
--- /dev/null
+++ b/docs/EXIF.md
@@ -0,0 +1,119 @@
+# EXIF metadata format
+
+* `Make`  
+  Manufacturer
+* `Model`  
+  Device
+* `DateTime`
+* `DateTimeDigitized`
+* `DateTimeOriginal`
+* `DigitalZoomRatio`  
+  Zoom  
+  - 0 unused
+* `ExposureBiasValue`  
+  Exposure  
+  -100.0 to +100.0
+* `ExposureMode`  
+  Exposure Mode  
+  - 0 = auto
+  - 1 = manual
+  - 2 = bracket
+* `ExposureProgram`  
+  Exposure Program
+  - 0 undefined 
+  - 1 manual
+  - 2 normal
+  - 3 aperture priority 
+  - 4 shutter priority
+  - 5 creative (depth of field priority)
+  - 6 action (fast shutter priority)
+  - 7 portrait (object separation priority)
+  - 8 landscape (background in focus priority)
+* `ExposureTime`  
+  Shutter in seconds
+* `FNumber`
+  Aperture
+* `Flash`  
+  Bitfield of Flash metadata (bits counted from LSB to MSB)
+  - bit 0: flashFired
+  - bit 1: flashStrobeDetectionAvailable
+  - bit 2: flashStrobeDetected
+  - bit 3-4
+    - 0 undefined 
+    - 1 always on
+    - 2 always off
+    - 3 auto 
+  - bit 5: flashAvailable
+  - bit 6: redEyeReductionAvailable
+* `FlashEnergy`  
+  Strobe energy in BCPS
+* `FocalLength`  
+  Focal Length in mm
+* `FocalLengthIn35mmFilm`
+  Focal Length in mm compared to a 35mm Film equivalent
+* `ISOSpeedRatings`
+  ISO exposure/speed rating
+* `LightSource`  
+  Type of lightsource for white balance
+  - 0 undefined
+  - 1 daylight
+  - 2 fluorescent
+  - 3 tungsten / incandescent
+  - 4 flash
+  - 9 fine weather
+  - 10 cloudy weather
+  - 11 shade
+  - 12 daylight fluorescent 5700-7100K
+  - 13 day white fluorescent 4600-5400K
+  - 14 cool white fluorescent 3900-4500K
+  - 15 white fluorescent 3200-3700K
+  - 17 standard light A
+  - 18 standard light B
+  - 19 standard light C
+  - 20 D55
+  - 21 D65
+  - 22 D75
+  - 23 D50
+  - 24 ISO studio tungsten
+  - 255 other
+* `MeteringMode`  
+  exposure metering mode
+  - 0 undefined
+  - 1 average
+  - 2 center weighted average
+  - 3 spot
+  - 4 multispot
+  - 5 pattern
+  - 6 partial
+  - 255 other
+* `WhiteBalance`  
+  White balance
+  - 0 auto
+  - 1 manual
+* `SceneCaptureType`  
+  Scene Mode
+  - 0 standard
+  - 1 landscape
+  - 2 portrait
+  - 3 night scene
+* `Contrast`  Contrast Processing
+  - 0 normal
+  - 1 soft
+  - 2 hard
+* `Sharpness`  
+  Sharpness Processing
+  - 0 normal
+  - 1 soft
+  - 2 hard
+* `SubjectDistance`  
+  Distance in meters
+* `SubjectDistanceRange`  
+  Distance Type
+  - 0 unknown
+  - 1 macro
+  - 2 close
+  - 3 distant
+* `Software`  
+  Application and version used to generate the image
+* `Copyright`  
+  copyright information
diff --git a/model/image.go b/model/image.go
index 79c204a..b43c7fd 100644
--- a/model/image.go
+++ b/model/image.go
@@ -8,16 +8,17 @@ import (
 )
 
 type Image struct {
-	Id           string    `json:"id" db:"id"`
-	Owner        string    `json:"owner" db:"owner"`
-	Title        string    `json:"title" db:"title"`
-	Description  string    `json:"description" db:"description"`
-	CreatedAt    time.Time `json:"created_at" db:"created_at"`
-	UpdatedAt    time.Time `json:"updated_at" db:"updated_at"`
-	OriginalName string    `json:"original_name" db:"original_name"`
-	MimeType     string    `json:"mime_type" db:"type"`
-	State        string    `json:"state" db:"state"`
-	Url          string    `json:"url"`
+	Id           string            `json:"id" db:"id"`
+	Owner        string            `json:"owner" db:"owner"`
+	Title        string            `json:"title" db:"title"`
+	Description  string            `json:"description" db:"description"`
+	CreatedAt    time.Time         `json:"created_at" db:"created_at"`
+	UpdatedAt    time.Time         `json:"updated_at" db:"updated_at"`
+	OriginalName string            `json:"original_name" db:"original_name"`
+	MimeType     string            `json:"mime_type" db:"type"`
+	State        string            `json:"state" db:"state"`
+	Metadata     map[string]string `json:"metadata,omitempty"`
+	Url          string            `json:"url"`
 }
 
 func (image Image) VerifyOwner(user User) error {
diff --git a/task/image_resize_processor.go b/task/image_resize_processor.go
index 414478e..5152443 100644
--- a/task/image_resize_processor.go
+++ b/task/image_resize_processor.go
@@ -75,12 +75,43 @@ func (processor *ImageProcessor) ProcessTask(ctx context.Context, task *asynq.Ta
 		return
 	}
 
+	supportedMetadata := map[string]bool{
+		"Make":                  true,
+		"Model":                 true,
+		"DateTime":              true,
+		"DateTimeDigitized":     true,
+		"DateTimeOriginal":      true,
+		"DigitalZoomRatio":      true,
+		"ExposureBiasValue":     true,
+		"ExposureMode":          true,
+		"ExposureProgram":       true,
+		"ExposureTime":          true,
+		"FNumber":               true,
+		"Flash":                 true,
+		"FlashEnergy":           true,
+		"FocalLength":           true,
+		"FocalLengthIn35mmFilm": true,
+		"ISOSpeedRatings":       true,
+		"LightSource":           true,
+		"MeteringMode":          true,
+		"WhiteBalance":          true,
+		"Contrast":              true,
+		"Sharpness":             true,
+		"SubjectDistance":       true,
+		"SubjectDistanceRange":  true,
+		"Software":              true,
+		"Copyright":             true,
+	}
 	metadata := make(map[string]string)
 	for _, key := range wand.GetImageProperties("exif:*") {
 		if strings.HasPrefix(key, "exif:thumbnail:") {
 			continue
 		}
-		metadata[strings.TrimPrefix(key, "exif:")] = wand.GetImageProperty(key)
+		trimmedKey := strings.TrimPrefix(key, "exif:")
+		if !supportedMetadata[trimmedKey] {
+			continue
+		}
+		metadata[trimmedKey] = wand.GetImageProperty(key)
 	}
 	err = processor.env.Repositories.ImageMetadata.Update(payload.ImageId, metadata)
 	if err != nil {
diff --git a/ui/src/api/model/Image.ts b/ui/src/api/model/Image.ts
index e944e64..16683c3 100644
--- a/ui/src/api/model/Image.ts
+++ b/ui/src/api/model/Image.ts
@@ -8,5 +8,8 @@ export interface Image {
     original_name: string,
     mime_type: string,
     state: string,
+    metadata: {
+        [key: string]: string
+    },
     url: string,
 }
diff --git a/ui/src/components/ImageView.tsx b/ui/src/components/ImageView.tsx
index e6e62e6..9d57bc6 100644
--- a/ui/src/components/ImageView.tsx
+++ b/ui/src/components/ImageView.tsx
@@ -1,7 +1,18 @@
 import {Image} from "../api/model/Image";
-import React, {useState} from "react";
+import React, {useMemo, useState} from "react";
 import {useUpdateImage} from "../api/useUpdateImage";
 import {useDeleteImage} from "../api/useDeleteImage";
+import {parseMetadata, ratioToTime} from "../metadata/ImageMetadata";
+import {ratioToFloat} from "../metadata/Ratio";
+import {ExposureMode} from "../metadata/ExposureMode";
+import {ExposureProgram} from "../metadata/ExposureProgram";
+import {LightSource} from "../metadata/LightSource";
+import {MeteringMode} from "../metadata/MeteringMode";
+import {WhiteBalance} from "../metadata/WhiteBalance";
+import {SceneMode} from "../metadata/SceneMode";
+import {ContrastProcessing} from "../metadata/ContrastProcessing";
+import {SharpnessProcessing} from "../metadata/SharpnessProcessing";
+import {SubjectDistanceRange} from "../metadata/SubjectDistanceRange";
 
 export interface ImageProps {
     image: Image
@@ -13,6 +24,10 @@ export default function ImageView({image}: ImageProps) {
     const [title, setTitle] = useState<string>(image.title);
     const [description, setDescription] = useState<string>(image.description);
 
+    const metadata = useMemo(() =>
+            parseMetadata(image.metadata),
+        [image]);
+
     return (
         <div>
             <p>UpdateError: {JSON.stringify(updateError, null, 2)}</p>
@@ -45,6 +60,40 @@ export default function ImageView({image}: ImageProps) {
             <p>{image.created_at}</p>
             <p>{image.updated_at}</p>
             <p>{image.state}</p>
+            <h3>Metadata</h3>
+            <p><b>Make</b>: {metadata.make}</p>
+            <p><b>Model</b>: {metadata.model}</p>
+            <p><b>Software</b>: {metadata.software}</p>
+            <p><b>Copyright</b>: {metadata.copyright}</p>
+            <p><b>DateTime Created</b>: {metadata.dateTimeCreated?.toISOString()}</p>
+            <p><b>DateTime Digitized</b>: {metadata.dateTimeDigitized?.toISOString()}</p>
+            <p><b>DateTime Original</b>: {metadata.dateTimeOriginal?.toISOString()}</p>
+            <p><b>Digital Zoom</b>: {ratioToFloat(metadata.digitalZoomRatio)}</p>
+            <p><b>Exposure</b>: {ratioToFloat(metadata.exposure)}</p>
+            <p><b>Exposure Mode</b>: {metadata.exposureMode !== undefined ?
+                ExposureMode[metadata.exposureMode] : "null"}</p>
+            <p><b>Exposure Program</b>: {metadata.exposureProgram !== undefined ?
+                ExposureProgram[metadata.exposureProgram] : "null"}</p>
+            <p><b>Exposure Time</b>: {ratioToTime(metadata.exposureTime)}</p>
+            <p><b>Aperture</b>: {ratioToFloat(metadata.aperture)}</p>
+            <p><b>Focal Length</b>: {ratioToFloat(metadata.focalLength)}</p>
+            <p><b>Focal Length (35mm equivalent)</b>: {ratioToFloat(metadata.focalLength35mm)}</p>
+            <p><b>ISO</b>: {metadata.isoSpeedRating}</p>
+            <p><b>Light source</b>: {metadata.lightSource !== undefined ?
+                LightSource[metadata.lightSource] : "null"}</p>
+            <p><b>Metering mode</b>: {metadata.meteringMode !== undefined ?
+                MeteringMode[metadata.meteringMode] : "null"}</p>
+            <p><b>White balance</b>: {metadata.whiteBalance !== undefined ?
+                WhiteBalance[metadata.whiteBalance] : "null"}</p>
+            <p><b>Scene Mode</b>: {metadata.sceneMode !== undefined ?
+                SceneMode[metadata.sceneMode] : "null"}</p>
+            <p><b>Contrast Processing</b>: {metadata.contrast !== undefined ?
+                ContrastProcessing[metadata.contrast] : "null"}</p>
+            <p><b>Sharpness Processing</b>: {metadata.sharpness !== undefined ?
+                SharpnessProcessing[metadata.sharpness] : "null"}</p>
+            <p><b>Subject Distance</b>: {metadata.subjectDistance}</p>
+            <p><b>Subject Distance Range</b>: {metadata.subjectDistanceRange !== undefined ?
+                SubjectDistanceRange[metadata.subjectDistanceRange] : "null"}</p>
             <img src={image.url + "t"} alt=""/>
             <br/>
             <input
diff --git a/ui/src/metadata/ContrastProcessing.ts b/ui/src/metadata/ContrastProcessing.ts
new file mode 100644
index 0000000..91405fb
--- /dev/null
+++ b/ui/src/metadata/ContrastProcessing.ts
@@ -0,0 +1,5 @@
+export enum ContrastProcessing {
+    NORMAL = 0,
+    SOFT = 1,
+    HARD = 2,
+}
diff --git a/ui/src/metadata/ExposureMode.ts b/ui/src/metadata/ExposureMode.ts
new file mode 100644
index 0000000..2518b99
--- /dev/null
+++ b/ui/src/metadata/ExposureMode.ts
@@ -0,0 +1,5 @@
+export enum ExposureMode {
+    AUTO = 0,
+    MANUAL = 1,
+    BRACKET = 2,
+}
diff --git a/ui/src/metadata/ExposureProgram.ts b/ui/src/metadata/ExposureProgram.ts
new file mode 100644
index 0000000..c056927
--- /dev/null
+++ b/ui/src/metadata/ExposureProgram.ts
@@ -0,0 +1,10 @@
+export enum ExposureProgram {
+    MANUAL = 1,
+    NORMAL = 2,
+    APERTURE_PRIORITY = 3,
+    SHUTTER_PRIORITY = 4,
+    CREATIVE = 5,
+    ACTION = 6,
+    PORTRAIT = 7,
+    LANDSCAPE = 8,
+}
diff --git a/ui/src/metadata/Flash.ts b/ui/src/metadata/Flash.ts
new file mode 100644
index 0000000..2a96677
--- /dev/null
+++ b/ui/src/metadata/Flash.ts
@@ -0,0 +1,12 @@
+import {FlashMode} from "./FlashMode";
+
+export interface Flash {
+    available: boolean,
+    fired: boolean,
+    strobeDetection: {
+        available: boolean,
+        detected: boolean,
+    },
+    mode: FlashMode | null,
+    redEyeReduction: boolean,
+}
diff --git a/ui/src/metadata/FlashMode.ts b/ui/src/metadata/FlashMode.ts
new file mode 100644
index 0000000..02f7379
--- /dev/null
+++ b/ui/src/metadata/FlashMode.ts
@@ -0,0 +1,5 @@
+export enum FlashMode {
+    ALWAYS_ON = 1,
+    ALWAYS_OFF = 2,
+    AUTO = 3
+}
diff --git a/ui/src/metadata/ImageMetadata.ts b/ui/src/metadata/ImageMetadata.ts
new file mode 100644
index 0000000..12ff5d2
--- /dev/null
+++ b/ui/src/metadata/ImageMetadata.ts
@@ -0,0 +1,204 @@
+import {parseRatio, Ratio} from "./Ratio";
+import {Flash} from "./Flash";
+import {ExposureMode} from "./ExposureMode";
+import {ExposureProgram} from "./ExposureProgram";
+import {LightSource} from "./LightSource";
+import {MeteringMode} from "./MeteringMode";
+import {WhiteBalance} from "./WhiteBalance";
+import {SceneMode} from "./SceneMode";
+import {ContrastProcessing} from "./ContrastProcessing";
+import {SharpnessProcessing} from "./SharpnessProcessing";
+import {SubjectDistanceRange} from "./SubjectDistanceRange";
+
+export interface ImageMetadata {
+    make?: string,
+    model?: string,
+    software?: string,
+    copyright?: string,
+    dateTimeCreated?: Date,
+    dateTimeDigitized?: Date,
+    dateTimeOriginal?: Date,
+    digitalZoomRatio?: Ratio,
+    exposure?: Ratio,
+    exposureMode?: ExposureMode,
+    exposureProgram?: ExposureProgram,
+    exposureTime?: Ratio,
+    aperture?: Ratio,
+    flash?: Flash,
+    focalLength?: Ratio,
+    focalLength35mm?: Ratio,
+    isoSpeedRating?: number,
+    lightSource?: LightSource,
+    meteringMode?: MeteringMode,
+    whiteBalance?: WhiteBalance,
+    sceneMode?: SceneMode,
+    contrast?: ContrastProcessing,
+    sharpness?: SharpnessProcessing,
+    subjectDistance?: number,
+    subjectDistanceRange?: SubjectDistanceRange,
+}
+
+export function parseMetadata(metadata: { [key: string]: string }): ImageMetadata {
+    return {
+        make: metadata["Make"],
+        model: metadata["Model"],
+        software: metadata["Software"],
+        copyright: metadata["Copyright"],
+        dateTimeCreated: parseDate(metadata["DateTime"]),
+        dateTimeDigitized: parseDate(metadata["DateTimeDigitized"]),
+        dateTimeOriginal: parseDate(metadata["DateTimeOriginal"]),
+        digitalZoomRatio: parseRatio(metadata["DigitalZoomRatio"]),
+        exposure: parseRatio(metadata["ExposureBiasValue"]),
+        exposureMode: parseExposureMode(metadata["ExposureMode"]),
+        exposureProgram: parseExposureProgram(metadata["ExposureProgram"]),
+        exposureTime: parseRatio(metadata["ExposureTime"]),
+        aperture: parseRatio(metadata["FNumber"]),
+        flash: undefined,
+        focalLength: parseRatio(metadata["FocalLength"]),
+        focalLength35mm: parseRatio(metadata["FocalLengthIn35mmFilm"]),
+        isoSpeedRating: parseNumber(metadata["ISOSpeedRatings"]),
+        lightSource: parseLightSource(metadata["LightSource"]),
+        meteringMode: parseMeteringMode(metadata["MeteringMode"]),
+        whiteBalance: parseWhiteBalance(metadata["WhiteBalance"]),
+        sceneMode: parseSceneMode(metadata["SceneMode"]),
+        contrast: parseContrastProcessing(metadata["Contrast"]),
+        sharpness: parseSharpnessProcessing(metadata["Sharpness"]),
+        subjectDistance: parseNumber(metadata["SubjectDistance"]),
+        subjectDistanceRange: parseSubjectDistanceRange(metadata["SubjectDistanceRange"]),
+    }
+}
+
+export function parseDate(value: string): Date | undefined {
+    const split = value.split(" ");
+    if (split.length !== 2) {
+        return undefined;
+    }
+    const [date, time] = split;
+    try {
+        const parsed = new Date(
+            date.replaceAll(":", "-") + " " + time
+        );
+        parsed.toISOString();
+        return parsed;
+    } catch (e) {
+        return undefined;
+    }
+}
+
+export function parseNumber(value: string): number | undefined {
+    const number = parseInt(value);
+    if (isNaN(number) || number === Infinity || number === -Infinity) {
+        return undefined;
+    }
+    return number;
+}
+
+export function parseExposureMode(value: string): ExposureMode | undefined {
+    const numericValue = parseNumber(value)
+    if (numericValue === undefined) {
+        return undefined;
+    }
+    if (numericValue in Object.values(ExposureMode)) {
+        return numericValue as ExposureMode;
+    }
+    return undefined;
+}
+
+export function parseExposureProgram(value: string): ExposureProgram | undefined {
+    const numericValue = parseNumber(value);
+    if (numericValue === undefined) {
+        return undefined;
+    }
+    if (numericValue in Object.values(ExposureProgram)) {
+        return numericValue as ExposureProgram;
+    }
+    return undefined;
+}
+
+export function parseLightSource(value: string): LightSource | undefined {
+    const numericValue = parseNumber(value);
+    if (numericValue === undefined) {
+        return undefined;
+    }
+    if (numericValue in Object.values(LightSource)) {
+        return numericValue as LightSource;
+    }
+    return undefined;
+}
+
+export function parseMeteringMode(value: string): MeteringMode | undefined {
+    const numericValue = parseNumber(value);
+    if (numericValue === undefined) {
+        return undefined;
+    }
+    if (numericValue in Object.values(MeteringMode)) {
+        return numericValue as MeteringMode;
+    }
+    return undefined;
+}
+
+export function parseWhiteBalance(value: string): WhiteBalance | undefined {
+    const numericValue = parseNumber(value);
+    if (numericValue === undefined) {
+        return undefined;
+    }
+    if (numericValue in Object.values(WhiteBalance)) {
+        return numericValue as WhiteBalance;
+    }
+    return undefined;
+}
+
+export function parseSceneMode(value: string): SceneMode | undefined {
+    const numericValue = parseNumber(value);
+    if (numericValue === undefined) {
+        return undefined;
+    }
+    if (numericValue in Object.values(SceneMode)) {
+        return numericValue as SceneMode;
+    }
+    return undefined;
+}
+
+export function parseContrastProcessing(value: string): ContrastProcessing | undefined {
+    const numericValue = parseNumber(value);
+    if (numericValue === undefined) {
+        return undefined;
+    }
+    if (numericValue in Object.values(ContrastProcessing)) {
+        return numericValue as ContrastProcessing;
+    }
+    return undefined;
+}
+
+export function parseSharpnessProcessing(value: string): SharpnessProcessing | undefined {
+    const numericValue = parseNumber(value);
+    if (numericValue === undefined) {
+        return undefined;
+    }
+    if (numericValue in Object.values(SharpnessProcessing)) {
+        return numericValue as SharpnessProcessing;
+    }
+    return undefined;
+}
+
+export function parseSubjectDistanceRange(value: string): SubjectDistanceRange | undefined {
+    const numericValue = parseNumber(value);
+    if (numericValue === undefined) {
+        return undefined;
+    }
+    if (numericValue in Object.values(SubjectDistanceRange)) {
+        return numericValue as SubjectDistanceRange;
+    }
+    return undefined;
+}
+
+export function ratioToTime(value: Ratio | undefined): string | undefined {
+    if (value === undefined) {
+        return undefined;
+    }
+    if (value.numerator > value.denominator) {
+        return (value.numerator / value.denominator).toFixed(0) + "s";
+    } else {
+        return "1/" + (value.denominator / value.numerator) + "s";
+    }
+}
diff --git a/ui/src/metadata/LightSource.ts b/ui/src/metadata/LightSource.ts
new file mode 100644
index 0000000..46d3c65
--- /dev/null
+++ b/ui/src/metadata/LightSource.ts
@@ -0,0 +1,21 @@
+export enum LightSource {
+    DAYLIGHT = 1,
+    FLUORESCENT = 2,
+    INCANDESCENT = 3,
+    FLASH = 4,
+    FINE_WEATHER = 9,
+    CLOUDY_WEATHER = 10,
+    SHADE = 11,
+    FLUORESCENT_6400K = 12,
+    FLUORESCENT_5000K = 13,
+    FLUORESCENT_4200K = 14,
+    FLUORESCENT_3450K = 15,
+    STANDARD_LIGHT_A = 17,
+    STANDARD_LIGHT_B = 18,
+    STANDARD_LIGHT_C = 10,
+    D55 = 20,
+    D65 = 21,
+    D75 = 22,
+    D50 = 23,
+    ISO_STUDIO_INCANDESCENT = 24,
+}
diff --git a/ui/src/metadata/MeteringMode.ts b/ui/src/metadata/MeteringMode.ts
new file mode 100644
index 0000000..38572e8
--- /dev/null
+++ b/ui/src/metadata/MeteringMode.ts
@@ -0,0 +1,8 @@
+export enum MeteringMode {
+    AVERAGE = 1,
+    CENTER_WEIGHTED_AVERAGE = 2,
+    SPOT = 3,
+    MULTI_SPOT = 4,
+    PATTERN = 5,
+    PARTIAL = 6,
+}
diff --git a/ui/src/metadata/Ratio.ts b/ui/src/metadata/Ratio.ts
new file mode 100644
index 0000000..f5a3c5e
--- /dev/null
+++ b/ui/src/metadata/Ratio.ts
@@ -0,0 +1,33 @@
+export interface Ratio {
+    numerator: number,
+    denominator: number
+}
+
+export function parseRatio(value: string): Ratio | undefined {
+    const splitValues = value.split("/");
+    if (splitValues.length < 1) {
+        return undefined;
+    }
+    const numerator = parseInt(splitValues[0]);
+    if (isNaN(numerator)) {
+        return undefined;
+    }
+    let denominator;
+    if (splitValues.length === 1) {
+        denominator = 1;
+    } else {
+        denominator = parseInt(splitValues[1]);
+        if (isNaN(denominator) || denominator === 0) {
+            return undefined;
+        }
+    }
+
+    return {numerator, denominator};
+}
+
+export function ratioToFloat(ratio: Ratio | undefined): number | undefined {
+    if (ratio === undefined) {
+        return undefined;
+    }
+    return ratio.numerator / ratio.denominator;
+}
diff --git a/ui/src/metadata/SceneMode.ts b/ui/src/metadata/SceneMode.ts
new file mode 100644
index 0000000..9c04599
--- /dev/null
+++ b/ui/src/metadata/SceneMode.ts
@@ -0,0 +1,6 @@
+export enum SceneMode {
+    STANDARD = 0,
+    LANDSCAPE = 1,
+    PORTRAIT = 2,
+    NIGHT_SCENE = 3,
+}
diff --git a/ui/src/metadata/SharpnessProcessing.ts b/ui/src/metadata/SharpnessProcessing.ts
new file mode 100644
index 0000000..f8d0a5f
--- /dev/null
+++ b/ui/src/metadata/SharpnessProcessing.ts
@@ -0,0 +1,5 @@
+export enum SharpnessProcessing {
+    NORMAL = 0,
+    SOFT = 1,
+    HARD = 2,
+}
diff --git a/ui/src/metadata/SubjectDistanceRange.ts b/ui/src/metadata/SubjectDistanceRange.ts
new file mode 100644
index 0000000..f28a4b7
--- /dev/null
+++ b/ui/src/metadata/SubjectDistanceRange.ts
@@ -0,0 +1,5 @@
+export enum SubjectDistanceRange {
+    MACRO = 1,
+    CLOSE = 2,
+    DISTANT = 3,
+}
diff --git a/ui/src/metadata/WhiteBalance.ts b/ui/src/metadata/WhiteBalance.ts
new file mode 100644
index 0000000..7b13f42
--- /dev/null
+++ b/ui/src/metadata/WhiteBalance.ts
@@ -0,0 +1,4 @@
+export enum WhiteBalance {
+    AUTO = 0,
+    MANUAL = 1,
+}
-- 
GitLab