1
0
mirror of https://github.com/xuthus83/pigallery2.git synced 2024-11-03 21:04:03 +08:00

Merge pull request #891 from grasdk/feature/Clear-DateTime-Tag-Priority

Feature/clear date time tag priority
This commit is contained in:
Patrik J. Braun 2024-04-19 20:13:23 +02:00 committed by GitHub
commit c82abf05d6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 271 additions and 71 deletions

View File

@ -0,0 +1,30 @@
type MainDateTag = string; //The main date tag, where the date is to be found. Typically has both date, time and offset. Except (see SecondaryDateTag and -Type)
type SecondaryDateTag = string | undefined; //The secondary date tag, which is sometimes needed to get a full date (sometimes contains offset, sometimes the time part of a timestamp)
type SecondaryDateTagType = 'O' | 'T' | undefined; //The secondary date tag type: 'O' for offset, 'T' for time undefined, if there is no secondary tag for the main tag
//Interesting exiftool forums posts about some of these tags:
//https://exiftool.org/forum/index.php?topic=13170.msg71174#msg71174 - about the meaning of exif.DateTimeOriginal, exif.CreateDate/exif.DateTimeDigitized and exif.ModifyDate
//https://exiftool.org/forum/index.php?topic=15555.msg83536#msg83536
//This is the PRIORITIZED LIST of tags which Pigallery2 uses to determine the date of creation of pictures.
//The list is used for embedded picture metadata and xmp-sidecar files for both pictures and vidoes.
export const DateTags: [MainDateTag, SecondaryDateTag, SecondaryDateTagType][] = [
// Date tag Offset or time tag Type //Description
["exif.DateTimeOriginal", "exif.OffsetTimeOriginal", 'O'], //Date and time when the original image was taken - shutter close time
["exif.CreateDate", "exif.OffsetTimeDigitized", 'O'], //Date and time when the image was created
["exif.DateTimeDigitized", "exif.OffsetTimeDigitized", 'O'], //Same as exif.CreateDate but older and newer spec name
["ifd0.ModifyDate", undefined, undefined], //The date and time of image creation. In Exif standard, it is the date and time the file was changed.
["ihdr.Creation Time", undefined, undefined], //Time of original image creation for PNG files
["photoshop.DateCreated", undefined, undefined], //The date the intellectual content of the document was created. Used and set by LightRoom among others
["xmp.CreateDate", undefined, undefined], //Date and time when the image was created (XMP standard)
["iptc.DateCreated", "iptc.TimeCreated", 'T'], //Designates the date and optionally the time the content of the image was created rather than the date of the creation of the digital representation
["quicktime.CreationDate", undefined, undefined], //Date and time when the QuickTime movie was created"],
["quicktime.CreateDate", undefined, undefined], //Date and time when the QuickTime movie was created in UTC"],
["heic.ContentCreateDate", undefined, undefined], //Date and time when the HEIC image content was created"],
["heic.CreationDate", undefined, undefined], //Date and time when the HEIC image was created"],
["tiff.DateTime", undefined, undefined], //Date and time of image creation.
["exif.ModifyDate", "exif.OffsetTime", 'O'], //Modification date
["xmp.ModifyDate", undefined, undefined], //Date and time when the image was last modified (XMP standard)"]
["xmp.MetadataDate", undefined, undefined], //The date and time that any metadata for this resource was last changed. It should be the same as or more recent than xmp:ModifyDate.
];

View File

@ -15,6 +15,7 @@ import * as path from 'path';
import { Utils } from '../../../common/Utils';
import { FFmpegFactory } from '../FFmpegFactory';
import { ExtensionDecorator } from '../extension/ExtensionDecorator';
import { DateTags } from './MetadataCreationDate';
const LOG_TAG = '[MetadataLoader]';
const ffmpeg = FFmpegFactory.get();
@ -248,7 +249,9 @@ export class MetadataLoader {
if (fs.existsSync(sidecarPath)) {
const sidecarData: any = await exifr.sidecar(sidecarPath, exifrOptions);
if (sidecarData !== undefined) {
//note that since side cars are loaded last, data loaded here overwrites embedded metadata (in Pigallery2, not in the actual files)
MetadataLoader.mapMetadata(metadata, sidecarData);
break;
}
}
}
@ -361,50 +364,69 @@ export class MetadataLoader {
}
private static mapTimestampAndOffset(metadata: PhotoMetadata, exif: any) {
metadata.creationDate = Utils.timestampToMS(exif?.photoshop?.DateCreated, null) ||
Utils.timestampToMS(exif?.xmp?.CreateDate, null) ||
Utils.timestampToMS(exif?.xmp?.ModifyDate, null) ||
Utils.timestampToMS(Utils.toIsoTimestampString(exif?.iptc?.DateCreated, exif?.iptc?.TimeCreated), null) ||
metadata.creationDate;
metadata.creationDateOffset = Utils.timestampToOffsetString(exif?.photoshop?.DateCreated) ||
Utils.timestampToOffsetString(exif?.xmp?.CreateDate) ||
metadata.creationDateOffset;
if (exif.exif) {
let offset = undefined;
//Preceedence of dates: exif.DateTimeOriginal, exif.CreateDate, ifd0.ModifyDate, ihdr["Creation Time"], xmp.MetadataDate, file system date
//Filesystem is the absolute last resort, and it's hard to write tests for, since file system dates are changed on e.g. git clone.
if (exif.exif.DateTimeOriginal) {
//DateTimeOriginal is when the camera shutter closed
offset = exif.exif.OffsetTimeOriginal; //OffsetTimeOriginal is the corresponding offset
if (!offset) { //Find offset among other options if possible
offset = exif.exif.OffsetTimeDigitized || exif.exif.OffsetTime || Utils.getTimeOffsetByGPSStamp(exif.exif.DateTimeOriginal, exif.exif.GPSTimeStamp, exif.gps);
//This method looks for date tags matching the priorized list 'DateTags' of 'MetadataCreationDate'
let ts: string, offset: string;
for (let i = 0; i < DateTags.length; i++) {
const [mainpath, extrapath, extratype] = DateTags[i];
[ts, offset] = extractTSAndOffset(mainpath, extrapath, extratype);
if (ts) {
if (!offset) { //We don't have the offset from the timestamp or from extra tag, let's see if we can find it in another way
//Check the explicit offset tags. Otherwise calculate from GPS
offset = exif.exif?.OffsetTimeOriginal || exif.exif?.OffsetTimeDigitized || exif.exif?.OffsetTime || Utils.getTimeOffsetByGPSStamp(ts, exif.exif?.GPSTimeStamp, exif.gps);
}
metadata.creationDate = Utils.timestampToMS(exif.exif.DateTimeOriginal, offset) || metadata.creationDate;
} else if (exif.exif.CreateDate) { //using else if here, because DateTimeOriginal has preceedence
//Create is when the camera wrote the file (typically within the same ms as shutter close)
offset = exif.exif.OffsetTimeDigitized; //OffsetTimeDigitized is the corresponding offset
if (!offset) { //Find offset among other options if possible
offset = exif.exif.OffsetTimeOriginal || exif.exif.OffsetTime || Utils.getTimeOffsetByGPSStamp(exif.exif.DateTimeOriginal, exif.exif.GPSTimeStamp, exif.gps);
if (!offset) { //still no offset? let's look for a timestamp with offset in the rest of the DateTags list
const [tsonly, tsoffset] = Utils.splitTimestampAndOffset(ts);
for (let j = i+1; j < DateTags.length; j++) {
const [exts, exOffset] = extractTSAndOffset(DateTags[j][0], DateTags[j][1], DateTags[j][2]);
if (exts && exOffset && Math.abs(Utils.timestampToMS(tsonly, null) - Utils.timestampToMS(exts, null)) < 30000) {
//if there is an offset and the found timestamp is within 30 seconds of the extra timestamp, we will use the offset from the found timestamp
offset = exOffset;
break;
}
}
}
metadata.creationDate = Utils.timestampToMS(exif.exif.CreateDate, offset) || metadata.creationDate;
} else if (exif.ifd0?.ModifyDate) { //using else if here, because DateTimeOriginal and CreatDate have preceedence
offset = exif.exif.OffsetTime; //exif.Offsettime is the offset corresponding to ifd0.ModifyDate
if (!offset) { //Find offset among other options if possible
offset = exif.exif.DateTimeOriginal || exif.exif.OffsetTimeDigitized || Utils.getTimeOffsetByGPSStamp(exif.ifd0.ModifyDate, exif.exif.GPSTimeStamp, exif.gps);
}
metadata.creationDate = Utils.timestampToMS(exif.ifd0.ModifyDate, offset) || metadata.creationDate;
} else if (exif.ihdr && exif.ihdr["Creation Time"]) {// again else if (another fallback date if the good ones aren't there) {
const any_offset = exif.exif.DateTimeOriginal || exif.exif.OffsetTimeDigitized || exif.exif.OffsetTime || Utils.getTimeOffsetByGPSStamp(exif.ifd0.ModifyDate, exif.exif.GPSTimeStamp, exif.gps);
metadata.creationDate = Utils.timestampToMS(exif.ihdr["Creation Time"], any_offset);
offset = any_offset;
} else if (exif.xmp?.MetadataDate) {// again else if (another fallback date if the good ones aren't there - metadata date is probably later than actual creation date, but much better than file time) {
const any_offset = exif.exif.DateTimeOriginal || exif.exif.OffsetTimeDigitized || exif.exif.OffsetTime || Utils.getTimeOffsetByGPSStamp(exif.ifd0.ModifyDate, exif.exif.GPSTimeStamp, exif.gps);
metadata.creationDate = Utils.timestampToMS(exif.xmp.MetadataDate, any_offset) || metadata.creationDate;
offset = any_offset;
break; //timestamp is found, look no further
}
metadata.creationDateOffset = offset || metadata.creationDateOffset;
}
metadata.creationDate = Utils.timestampToMS(ts, offset) || metadata.creationDate;
metadata.creationDateOffset = offset || metadata.creationDateOffset;
//---- End of mapTimestampAndOffset logic ----
//---- Helper functions for mapTimestampAndOffset ----
function getValue(exif: any, path: string): any {
const pathElements = path.split('.');
let currentObject: any = exif;
for (const pathElm of pathElements) {
const tmp = currentObject[pathElm];
if (tmp === undefined) {
return undefined;
}
currentObject = tmp;
}
return currentObject;
}
function extractTSAndOffset(mainpath: string, extrapath: string, extratype: string) {
let ts: string | undefined = undefined;
let offset: string | undefined = undefined;
//line below is programmatic way of finding a timestamp in the exif object. For example "xmp.CreateDate", from the DateTags list
//ts = exif.xmp?.CreateDate
ts = getValue(exif, mainpath);
if (ts) {
if (!extratype || extratype == 'O') { //offset can be in the timestamp itself
[ts, offset] = Utils.splitTimestampAndOffset(ts);
if (extratype == 'O' && !offset) { //offset in the extra tag and not already extracted from main tag
offset = getValue(exif, extrapath);
}
} else if (extratype == 'T') { //date only in main tag, time in the extra tag
ts = Utils.toIsoTimestampString(ts, getValue(exif, extrapath));
[ts, offset] = Utils.splitTimestampAndOffset(ts);
}
}
return [ts, offset];
}
}
private static mapCameraData(metadata: PhotoMetadata, exif: any) {

View File

@ -56,11 +56,16 @@ export class Utils {
return c;
}
static zeroPrefix(value: string | number, length: number): string {
const ret = '00000' + value;
return ret.substr(ret.length - length);
static zeroPrefix(number: any, length: number): string {
if (!isNaN(number)) {
const zerosToAdd = Math.max(length - String(number).length, 0);
return '0'.repeat(zerosToAdd) + number;
} else {
return '0'.repeat(number);
}
}
/**
* Checks if the two input (let them be objects or arrays or just primitives) are equal
*/
@ -118,7 +123,6 @@ export class Utils {
}
}
static makeUTCMidnight(d: number | Date) {
if (!(d instanceof Date)) {
d = new Date(d);
@ -162,22 +166,22 @@ export class Utils {
return Date.parse(formattedTimestamp);
}
//function to extract offset string from timestamp string, returns undefined if timestamp does not contain offset
static timestampToOffsetString(timestamp: string) {
try {
const offsetRegex = /[+-]\d{2}:\d{2}$/;
const match = timestamp.match(offsetRegex);
if (match) {
return match[0];
} else if (timestamp.indexOf("Z") > 0) {
return '+00:00';
}
return undefined;
} catch (err) {
return undefined;
static splitTimestampAndOffset(timestamp: string): [string|undefined, string|undefined] {
if (!timestamp) {
return [undefined, undefined];
}
// |---------------------TIMESTAMP WITH OPTIONAL MILLISECONDS--------------------||-OPTIONAL TZONE--|
// |YYYY MM DD HH MM SS (MS optio)||(timezone offset)|
const timestampWithOffsetRegex = /^(\d{4}[-.: ]\d{2}[-.: ]\d{2}[-.: T]\d{2}[-.: ]\d{2}[-.: ]\d{2}(?:\.\d+)?)([+-]\d{2}:\d{2})?$/;
const match = timestamp.match(timestampWithOffsetRegex);
if (match) {
return [match[1], match[2]]; //match[0] is the full string, not interested in that.
} else {
return [undefined, undefined];
}
}
//function to calculate offset from exif.exif.gpsTimeStamp or exif.gps.GPSDateStamp + exif.gps.GPSTimestamp
static getTimeOffsetByGPSStamp(timestamp: string, gpsTimeStamp: string, gps: any) {
let UTCTimestamp = gpsTimeStamp;
@ -186,7 +190,7 @@ export class Utils {
gps.GPSDateStamp &&
gps.GPSTimeStamp) { //else use exif.gps.GPS*Stamp if available
//GPS timestamp is always UTC (+00:00)
UTCTimestamp = gps.GPSDateStamp.replaceAll(':', '-') + " " + gps.GPSTimeStamp.map((num: any) => Utils.zeroPad(num ,2)).join(':');
UTCTimestamp = gps.GPSDateStamp.replaceAll(':', '-') + " " + gps.GPSTimeStamp.map((num: any) => Utils.zeroPrefix(num ,2)).join(':');
}
if (UTCTimestamp && timestamp) {
//offset in minutes is the difference between gps timestamp and given timestamp
@ -202,22 +206,13 @@ export class Utils {
if (-720 <= offsetMinutes && offsetMinutes <= 840) {
//valid offset is within -12 and +14 hrs (https://en.wikipedia.org/wiki/List_of_UTC_offsets)
return (offsetMinutes < 0 ? "-" : "+") + //leading +/-
Utils.zeroPad(Math.trunc(Math.abs(offsetMinutes) / 60), 2) + ":" + //zeropadded hours and ':'
Utils.zeroPad((Math.abs(offsetMinutes) % 60), 2); //zeropadded minutes
Utils.zeroPrefix(Math.trunc(Math.abs(offsetMinutes) / 60), 2) + ":" + //zeropadded hours and ':'
Utils.zeroPrefix((Math.abs(offsetMinutes) % 60), 2); //zeropadded minutes
} else {
return undefined;
}
}
static zeroPad(number: any, length: number): string {
if (!isNaN(number)) {
const zerosToAdd = Math.max(length - String(number).length, 0);
return '0'.repeat(zerosToAdd) + number;
} else {
return '0'.repeat(number);
}
}
static getOffsetMinutes(offsetString: string) { //Convert offset string (+HH:MM or -HH:MM) into a minute value
const regex = /^([+-](0[0-9]|1[0-4]):[0-5][0-9])$/; //checks if offset is between -14:00 and +14:00.
//-12:00 is the lowest valid UTC-offset, but we allow down to -14 for efficiency
@ -230,6 +225,11 @@ export class Utils {
}
}
static getLocalTimeMS(creationDate: number, creationDateOffset: string) {
const offsetMinutes = Utils.getOffsetMinutes(creationDateOffset);
return creationDate + (offsetMinutes ? (offsetMinutes * 60000) : 0);
}
static isLeapYear(year: number) {
return (0 == year % 4) && (0 != year % 100) || (0 == year % 400)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

View File

@ -0,0 +1,18 @@
{
"creationDate": 1713040470000,
"creationDateOffset": "+12:00",
"fileSize": 62150,
"positionData": {
"GPSData": {
"latitude": -36.854351,
"longitude": 174.755402
},
"city": "Arch Hill",
"country": "New Zealand",
"state": "Auckland"
},
"size": {
"height": 1080,
"width": 1920
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

View File

@ -0,0 +1,18 @@
{
"creationDate": 1713040530000,
"creationDateOffset": "-03:00",
"fileSize": 64033,
"positionData": {
"GPSData": {
"latitude": -34.862412,
"longitude": -56.154556
},
"city": "Jacinto Vera",
"country": "Uruguay",
"state": "Montevideo"
},
"size": {
"height": 1080,
"width": 1920
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

View File

@ -0,0 +1,17 @@
{
"creationDate": 1713040531000,
"creationDateOffset": "+05:45",
"fileSize": 62443,
"positionData": {
"GPSData": {
"latitude": 27.693999,
"longitude": 85.285262
},
"country": "Nepal",
"state": "Bagmati Province"
},
"size": {
"height": 1080,
"width": 1920
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

View File

@ -0,0 +1,18 @@
{
"creationDate": 1713040590000,
"creationDateOffset": "+01:00",
"fileSize": 60679,
"positionData": {
"GPSData": {
"latitude": 51.515618,
"longitude": -0.091998
},
"city": "Bassishaw",
"country": "Storbritannien",
"state": "England"
},
"size": {
"height": 1080,
"width": 1920
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

View File

@ -0,0 +1,18 @@
{
"creationDate": 1713040640000,
"creationDateOffset": "+09:00",
"fileSize": 60451,
"positionData": {
"GPSData": {
"latitude": 35.682194,
"longitude": 139.762221
},
"city": "Kokyogaien",
"country": "Japan",
"state": "Tokyo To"
},
"size": {
"height": 1080,
"width": 1920
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View File

@ -0,0 +1,17 @@
{
"creationDate": 1713040650000,
"creationDateOffset": "+00:00",
"fileSize": 59743,
"positionData": {
"GPSData": {
"latitude": 64.141166,
"longitude": -21.94313
},
"city": "Reykjavík - 1",
"country": "Island"
},
"size": {
"height": 1080,
"width": 1920
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

View File

@ -0,0 +1,18 @@
{
"creationDate": 1713040710000,
"creationDateOffset": "-04:00",
"fileSize": 62918,
"positionData": {
"GPSData": {
"latitude": 40.712728,
"longitude": -74.006015
},
"city": "New York",
"country": "USA",
"state": "New York"
},
"size": {
"height": 1080,
"width": 1920
}
}

View File

@ -1,6 +1,7 @@
{
"skip": [
"creationDate"
"creationDate",
"creationDateOffset"
],
"fileSize": 5860,
"size": {

View File

@ -4,7 +4,7 @@
"height": 5
},
"caption": "Description of image",
"creationDate": 328817998000,
"creationDate": 328839598000,
"creationDateOffset": "-06:00",
"faces": [
{

View File

@ -268,6 +268,7 @@ describe('MetadataLoader', () => {
const expected = require(path.join(__dirname, '/../../../assets/orientation/Landscape.json'));
delete data.fileSize;
delete data.creationDate;
delete data.creationDateOffset;
expect(Utils.clone(data)).to.be.deep.equal(expected);
});
it('Portrait ' + i, async () => {
@ -275,6 +276,7 @@ describe('MetadataLoader', () => {
const expected = require(path.join(__dirname, '/../../../assets/orientation/Portrait.json'));
delete data.fileSize;
delete data.creationDate;
delete data.creationDateOffset;
expect(Utils.clone(data)).to.be.deep.equal(expected);
});
}
@ -334,4 +336,25 @@ describe('MetadataLoader', () => {
}
}
});
describe('should load metadata from files with times and coordinates in different parts of the world', () => {
const root = path.join(__dirname, '/../../../assets/4MinsAroundTheWorld');
const files = fs.readdirSync(root);
for (const item of files) {
const fullFilePath = path.join(root, item);
if (PhotoProcessing.isPhoto(fullFilePath)) {
it(item, async () => {
const data = await MetadataLoader.loadPhotoMetadata(fullFilePath);
const expected = require(fullFilePath.split('.').slice(0, -1).join('.') + '.json');
expect(Utils.clone(data)).to.be.deep.equal(expected);
});
} else if (VideoProcessing.isVideo(fullFilePath)) {
it(item, async () => {
const data = await MetadataLoader.loadVideoMetadata(fullFilePath);
const expected = require(fullFilePath.split('.').slice(0, -1).join('.') + '.json');
expect(Utils.clone(data)).to.be.deep.equal(expected);
});
}
}
});
});