1
0
mirror of https://github.com/bpatrik/pigallery2.git synced 2025-04-15 11:56:50 +02:00
pigallery2/src/backend/model/fileaccess/MetadataLoader.ts
Graham Alderson 676a3b054b Refactor sidecar loading into async-await
Refactoring to async/await to solve race condition, also resolves error sometimes occuring with undefined values and resolves overwrite of xmp values.

xmp for image file has tag removed to validate additive tagging from both file xmp and sidecar xmp data.
2023-11-30 13:13:33 +12:00

534 lines
20 KiB
TypeScript

import {VideoMetadata} from '../../../common/entities/VideoDTO';
import {FaceRegion, PhotoMetadata} from '../../../common/entities/PhotoDTO';
import {Config} from '../../../common/config/private/Config';
import {Logger} from '../../Logger';
import * as fs from 'fs';
import {imageSize} from 'image-size';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import * as ExifReader from 'exifreader';
import {ExifParserFactory, OrientationTypes} from 'ts-exif-parser';
import {IptcParser} from 'ts-node-iptc';
import {FFmpegFactory} from '../FFmpegFactory';
import {FfprobeData} from 'fluent-ffmpeg';
import {Utils} from '../../../common/Utils';
import { ExtensionDecorator } from '../extension/ExtensionDecorator';
import * as exifr from 'exifr';
import * as path from 'path';
const LOG_TAG = '[MetadataLoader]';
const ffmpeg = FFmpegFactory.get();
export class MetadataLoader {
@ExtensionDecorator(e=>e.gallery.MetadataLoader.loadVideoMetadata)
public static loadVideoMetadata(fullPath: string): Promise<VideoMetadata> {
return new Promise<VideoMetadata>(async (resolve) => {
const metadata: VideoMetadata = {
size: {
width: 1,
height: 1,
},
bitRate: 0,
duration: 0,
creationDate: 0,
fileSize: 0,
fps: 0,
};
try {
// search for sidecar and merge metadata
const fullPathWithoutExt = await path.parse(fullPath).name;
const sidecarPaths = [
fullPath + '.xmp',
fullPath + '.XMP',
fullPathWithoutExt + '.xmp',
fullPathWithoutExt + '.XMP',
];
for (const sidecarPath of sidecarPaths) {
if (fs.existsSync(sidecarPath)) {
const sidecarData = await exifr.sidecar(sidecarPath);
if (sidecarData !== undefined) {
if ((sidecarData as any).dc.subject !== undefined) {
metadata.keywords = (sidecarData as any).dc.subject.flat();
}
if ((sidecarData as any).xmp.Rating !== undefined) {
metadata.rating = (sidecarData as any).xmp.Rating;
}
}
}
}
} catch (err) {
// ignoring errors
}
try {
const stat = fs.statSync(fullPath);
metadata.fileSize = stat.size;
metadata.creationDate = stat.mtime.getTime();
} catch (err) {
// ignoring errors
}
try {
ffmpeg(fullPath).ffprobe((err: unknown, data: FfprobeData) => {
if (!!err || data === null || !data.streams[0]) {
return resolve(metadata);
}
try {
for (const stream of data.streams) {
if (stream.width) {
metadata.size.width = stream.width;
metadata.size.height = stream.height;
if (
Utils.isInt32(parseInt('' + stream.rotation, 10)) &&
(Math.abs(parseInt('' + stream.rotation, 10)) / 90) % 2 === 1
) {
// noinspection JSSuspiciousNameCombination
metadata.size.width = stream.height;
// noinspection JSSuspiciousNameCombination
metadata.size.height = stream.width;
}
if (
Utils.isInt32(Math.floor(parseFloat(stream.duration) * 1000))
) {
metadata.duration = Math.floor(
parseFloat(stream.duration) * 1000
);
}
if (Utils.isInt32(parseInt(stream.bit_rate, 10))) {
metadata.bitRate = parseInt(stream.bit_rate, 10) || null;
}
if (Utils.isInt32(parseInt(stream.avg_frame_rate, 10))) {
metadata.fps = parseInt(stream.avg_frame_rate, 10) || null;
}
metadata.creationDate =
Date.parse(stream.tags.creation_time) ||
metadata.creationDate;
break;
}
}
// For some filetypes (for instance Matroska), bitrate and duration are stored in
// the format section, not in the stream section.
// Only use duration from container header if necessary (stream duration is usually more accurate)
if (
metadata.duration === 0 &&
data.format.duration !== undefined &&
Utils.isInt32(Math.floor(data.format.duration * 1000))
) {
metadata.duration = Math.floor(data.format.duration * 1000);
}
// Prefer bitrate from container header (includes video and audio)
if (
data.format.bit_rate !== undefined &&
Utils.isInt32(data.format.bit_rate)
) {
metadata.bitRate = data.format.bit_rate;
}
if (
data.format.tags !== undefined &&
typeof data.format.tags.creation_time === 'string'
) {
metadata.creationDate =
Date.parse(data.format.tags.creation_time) ||
metadata.creationDate;
}
// eslint-disable-next-line no-empty
} catch (err) {
}
metadata.creationDate = metadata.creationDate || 0;
return resolve(metadata);
});
} catch (e) {
return resolve(metadata);
}
});
}
private static readonly EMPTY_METADATA: PhotoMetadata = {
size: {width: 1, height: 1},
creationDate: 0,
fileSize: 0,
};
@ExtensionDecorator(e=>e.gallery.MetadataLoader.loadPhotoMetadata)
public static loadPhotoMetadata(fullPath: string): Promise<PhotoMetadata> {
return new Promise<PhotoMetadata>(async (resolve, reject) => {
const metadata: PhotoMetadata = {
size: {width: 1, height: 1},
creationDate: 0,
fileSize: 0,
};
try {
// search for sidecar and merge metadata
const fullPathWithoutExt = await path.parse(fullPath).name;
const sidecarPaths = [
fullPath + '.xmp',
fullPath + '.XMP',
fullPathWithoutExt + '.xmp',
fullPathWithoutExt + '.XMP',
];
for (const sidecarPath of sidecarPaths) {
if (fs.existsSync(sidecarPath)) {
const sidecarData = await exifr.sidecar(sidecarPath);
if (sidecarData !== undefined) {
if ((sidecarData as any).dc.subject !== undefined) {
metadata.keywords = (sidecarData as any).dc.subject.flat();
}
if ((sidecarData as any).xmp.Rating !== undefined) {
metadata.rating = (sidecarData as any).xmp.Rating;
}
}
}
}
} catch (err) {
// ignoring errors
}
try {
const fd = fs.openSync(fullPath, 'r');
const data = Buffer.allocUnsafe(Config.Media.photoMetadataSize);
fs.read(fd, data, 0, Config.Media.photoMetadataSize, 0, (err) => {
fs.closeSync(fd);
if (err) {
Logger.error(LOG_TAG, 'Error during reading photo: ' + fullPath);
console.error(err);
return resolve(MetadataLoader.EMPTY_METADATA);
}
try {
try {
const stat = fs.statSync(fullPath);
metadata.fileSize = stat.size;
metadata.creationDate = stat.mtime.getTime();
} catch (err) {
// ignoring errors
}
try {
const exif = ExifParserFactory.create(data).parse();
if (
exif.tags.ISO ||
exif.tags.Model ||
exif.tags.Make ||
exif.tags.FNumber ||
exif.tags.ExposureTime ||
exif.tags.FocalLength ||
exif.tags.LensModel
) {
if (exif.tags.Model && exif.tags.Model !== '') {
metadata.cameraData = metadata.cameraData || {};
metadata.cameraData.model = '' + exif.tags.Model;
}
if (exif.tags.Make && exif.tags.Make !== '') {
metadata.cameraData = metadata.cameraData || {};
metadata.cameraData.make = '' + exif.tags.Make;
}
if (exif.tags.LensModel && exif.tags.LensModel !== '') {
metadata.cameraData = metadata.cameraData || {};
metadata.cameraData.lens = '' + exif.tags.LensModel;
}
if (Utils.isUInt32(exif.tags.ISO)) {
metadata.cameraData = metadata.cameraData || {};
metadata.cameraData.ISO = parseInt('' + exif.tags.ISO, 10);
}
if (Utils.isFloat32(exif.tags.FocalLength)) {
metadata.cameraData = metadata.cameraData || {};
metadata.cameraData.focalLength = parseFloat(
'' + exif.tags.FocalLength
);
}
if (Utils.isFloat32(exif.tags.ExposureTime)) {
metadata.cameraData = metadata.cameraData || {};
metadata.cameraData.exposure = parseFloat(
parseFloat('' + exif.tags.ExposureTime).toFixed(6)
);
}
if (Utils.isFloat32(exif.tags.FNumber)) {
metadata.cameraData = metadata.cameraData || {};
metadata.cameraData.fStop = parseFloat(
parseFloat('' + exif.tags.FNumber).toFixed(2)
);
}
}
if (
!isNaN(exif.tags.GPSLatitude) ||
exif.tags.GPSLongitude ||
exif.tags.GPSAltitude
) {
metadata.positionData = metadata.positionData || {};
metadata.positionData.GPSData = {};
if (Utils.isFloat32(exif.tags.GPSLongitude)) {
metadata.positionData.GPSData.longitude = parseFloat(
exif.tags.GPSLongitude.toFixed(6)
);
}
if (Utils.isFloat32(exif.tags.GPSLatitude)) {
metadata.positionData.GPSData.latitude = parseFloat(
exif.tags.GPSLatitude.toFixed(6)
);
}
}
if (
exif.tags.CreateDate ||
exif.tags.DateTimeOriginal ||
exif.tags.ModifyDate
) {
metadata.creationDate =
(exif.tags.DateTimeOriginal ||
exif.tags.CreateDate ||
exif.tags.ModifyDate) * 1000;
}
if (exif.imageSize) {
metadata.size = {
width: exif.imageSize.width,
height: exif.imageSize.height,
};
} else if (
exif.tags.RelatedImageWidth &&
exif.tags.RelatedImageHeight
) {
metadata.size = {
width: exif.tags.RelatedImageWidth,
height: exif.tags.RelatedImageHeight,
};
} else if (
exif.tags.ImageWidth &&
exif.tags.ImageHeight
) {
metadata.size = {
width: exif.tags.ImageWidth,
height: exif.tags.ImageHeight,
};
} else {
const info = imageSize(fullPath);
metadata.size = {width: info.width, height: info.height};
}
} catch (err) {
Logger.debug(LOG_TAG, 'Error parsing exif', fullPath, err);
try {
const info = imageSize(fullPath);
metadata.size = {width: info.width, height: info.height};
} catch (e) {
metadata.size = {width: 1, height: 1};
}
}
try {
const iptcData = IptcParser.parse(data);
if (iptcData.country_or_primary_location_name) {
metadata.positionData = metadata.positionData || {};
metadata.positionData.country =
iptcData.country_or_primary_location_name
.replace(/\0/g, '')
.trim();
}
if (iptcData.province_or_state) {
metadata.positionData = metadata.positionData || {};
metadata.positionData.state = iptcData.province_or_state
.replace(/\0/g, '')
.trim();
}
if (iptcData.city) {
metadata.positionData = metadata.positionData || {};
metadata.positionData.city = iptcData.city
.replace(/\0/g, '')
.trim();
}
if (iptcData.caption) {
metadata.caption = iptcData.caption.replace(/\0/g, '').trim();
}
if (Array.isArray(iptcData.keywords)) {
if (metadata.keywords === undefined) {
metadata.keywords = iptcData.keywords;
}
}
if (iptcData.date_time) {
metadata.creationDate = iptcData.date_time.getTime();
}
} catch (err) {
// Logger.debug(LOG_TAG, 'Error parsing iptc data', fullPath, err);
}
if (!metadata.creationDate) {
// creationDate can be negative, when it was created before epoch (1970)
metadata.creationDate = 0;
}
try {
// TODO: clean up the three different exif readers,
// and keep the minimum amount only
const exif: ExifReader.Tags & ExifReader.XmpTags & ExifReader.IccTags = ExifReader.load(data);
if (exif.Rating) {
if (metadata.rating === undefined) {
metadata.rating = parseInt(exif.Rating.value as string, 10) as 0 | 1 | 2 | 3 | 4 | 5;
if (metadata.rating < 0) {
metadata.rating = 0;
}
}
}
if (
exif.subject &&
exif.subject.value &&
exif.subject.value.length > 0
) {
if (metadata.keywords === undefined) {
metadata.keywords = [];
}
for (const kw of exif.subject.value as ExifReader.XmpTag[]) {
if (metadata.keywords.indexOf(kw.description) === -1) {
metadata.keywords.push(kw.description);
}
}
}
let orientation = OrientationTypes.TOP_LEFT;
if (exif.Orientation) {
orientation = parseInt(
exif.Orientation.value as any,
10
) as number;
}
if (OrientationTypes.BOTTOM_LEFT < orientation) {
// noinspection JSSuspiciousNameCombination
const height = metadata.size.width;
// noinspection JSSuspiciousNameCombination
metadata.size.width = metadata.size.height;
metadata.size.height = height;
}
if (Config.Faces.enabled) {
const faces: FaceRegion[] = [];
const regionListVal = ((exif.Regions?.value as any)?.RegionList)?.value;
if (regionListVal) {
for (const regionRoot of regionListVal) {
let type;
let name;
let box;
const createFaceBox = (
w: string,
h: string,
x: string,
y: string
) => {
if (OrientationTypes.BOTTOM_LEFT < orientation) {
[x, y] = [y, x];
[w, h] = [h, w];
}
let swapX = 0;
let swapY = 0;
switch (orientation) {
case OrientationTypes.TOP_RIGHT:
case OrientationTypes.RIGHT_TOP:
swapX = 1;
break;
case OrientationTypes.BOTTOM_RIGHT:
case OrientationTypes.RIGHT_BOTTOM:
swapX = 1;
swapY = 1;
break;
case OrientationTypes.BOTTOM_LEFT:
case OrientationTypes.LEFT_BOTTOM:
swapY = 1;
break;
}
// converting ratio to px
return {
width: Math.round(parseFloat(w) * metadata.size.width),
height: Math.round(parseFloat(h) * metadata.size.height),
left: Math.round(Math.abs(parseFloat(x) - swapX) * metadata.size.width),
top: Math.round(Math.abs(parseFloat(y) - swapY) * metadata.size.height),
};
};
/* Adobe Lightroom based face region structure */
if (
regionRoot.value &&
regionRoot.value['rdf:Description'] &&
regionRoot.value['rdf:Description'].value &&
regionRoot.value['rdf:Description'].value['mwg-rs:Area']
) {
const region = regionRoot.value['rdf:Description'];
const regionBox = region.value['mwg-rs:Area'].attributes;
name = region.attributes['mwg-rs:Name'];
type = region.attributes['mwg-rs:Type'];
box = createFaceBox(
regionBox['stArea:w'],
regionBox['stArea:h'],
regionBox['stArea:x'],
regionBox['stArea:y']
);
/* Load exiftool edited face region structure, see github issue #191 */
} else if (
regionRoot.Area &&
regionRoot.Name &&
regionRoot.Type
) {
const regionBox = regionRoot.Area.value;
name = regionRoot.Name.value;
type = regionRoot.Type.value;
box = createFaceBox(
regionBox.w.value,
regionBox.h.value,
regionBox.x.value,
regionBox.y.value
);
}
if (type !== 'Face' || !name) {
continue;
}
// convert center base box to corner based box
box.left = Math.round(Math.max(0, box.left - box.width / 2));
box.top = Math.round(Math.max(0, box.top - box.height / 2));
faces.push({name, box});
}
}
if (faces.length > 0) {
metadata.faces = faces; // save faces
if (Config.Faces.keywordsToPersons) {
// remove faces from keywords
metadata.faces.forEach((f) => {
const index = metadata.keywords.indexOf(f.name);
if (index !== -1) {
metadata.keywords.splice(index, 1);
}
});
}
}
}
} catch (err) {
// ignoring errors
}
return resolve(metadata);
} catch (err) {
return reject({file: fullPath, error: err});
}
});
} catch (err) {
Logger.error(LOG_TAG, 'Error during reading photo: ' + fullPath);
console.error(err);
return resolve(MetadataLoader.EMPTY_METADATA);
}
});
}
}