Skip to content

Commit

Permalink
Feat: inferTimezoneFromTimeStamp. Fixes #209
Browse files Browse the repository at this point in the history
  • Loading branch information
mceachen committed Sep 22, 2024
1 parent 5b98623 commit 6b6e83b
Show file tree
Hide file tree
Showing 5 changed files with 80 additions and 2 deletions.
1 change: 1 addition & 0 deletions src/DefaultExifToolOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export const DefaultExifToolOptions: Omit<
includeImageDataMD5: undefined,
inferTimezoneFromDatestamps: false, // to retain prior behavior
inferTimezoneFromDatestampTags: [...CapturedAtTagNames],
inferTimezoneFromTimeStamp: false, // to retain prior behavior
logger,
numericTags: [
"*Duration*",
Expand Down
14 changes: 14 additions & 0 deletions src/ExifToolOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,20 @@ export interface ExifToolOptions
*/
inferTimezoneFromDatestampTags: (keyof Tags)[]

/**
* Some cameras (Samsung Galaxy S7, for example) may not always include GPS
* metadata in photos if a fix can't be obtained. If this option is true, and
* GPS metadata is missing, we'll try to infer the timezone from the
* difference of the TimeStamp tag and the first defined tag value from
* {@link inferTimezoneFromDatestampTags}.
*
* This heuristic is pretty sketchy, and used as a last resort. You shouldn't
* enable it unless you have to.
*
* @see https://github.com/photostructure/exiftool-vendored.js/issues/209
*/
inferTimezoneFromTimeStamp: boolean

/**
* Some software uses a GPS position of (0,0) as a synonym for "unset". If
* this option is true, and GPSLatitude and GPSLongitude are both 0, then
Expand Down
31 changes: 30 additions & 1 deletion src/ReadTask.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { omit } from "./Object"
import { pick } from "./Pick"
import { ReadTask, ReadTaskOptions } from "./ReadTask"
import { Tags } from "./Tags"
import { isUTC } from "./Timezones"

// eslint-disable-next-line @typescript-eslint/no-require-imports
const gt = require("geo-tz")
Expand Down Expand Up @@ -279,9 +280,37 @@ describe("ReadTask", () => {
})
expect(t.DateTimeOriginal).to.containSubset({
tzoffsetMinutes: 0,
zone: "UTC",
inferredZone: true,
})
expect(isUTC(ExifDateTime.from(t.DateTimeOriginal)?.zone)).to.eql(true)
})

describe("inferTimezoneFromTimeStamp (see #209)", () => {
it("disabled", () => {
const t = parse({
tags: {
DateTimeOriginal: "2016:10:17 09:40:43",
CreateDate: "2016:10:17 09:40:43",
TimeStamp: "2016:10:17 07:40:43.891-07:00",
},
inferTimezoneFromTimeStamp: false,
})
expect(t.tz).to.eql(undefined)
})
it("enabled", () => {
const t = parse({
tags: {
DateTimeOriginal: "2016:10:17 09:40:43",
CreateDate: "2016:10:17 09:40:43",
TimeStamp: "2016:10:17 07:40:43.891-07:00",
},
inferTimezoneFromTimeStamp: true,
})
expect(t).to.containSubset({
tz: "UTC-5",
tzSource: "offset between DateTimeOriginal and TimeStamp",
})
})
})

it("finds positive array TimeZoneOffset and sets accordingly", () => {
Expand Down
6 changes: 5 additions & 1 deletion src/ReadTask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { Tags } from "./Tags"
import {
extractTzOffsetFromDatestamps,
extractTzOffsetFromTags,
extractTzOffsetFromTimeStamp,
extractTzOffsetFromUTCOffset,
normalizeZone,
} from "./Timezones"
Expand Down Expand Up @@ -47,6 +48,7 @@ export const ReadTaskOptionFields = [
"includeImageDataMD5",
"inferTimezoneFromDatestamps",
"inferTimezoneFromDatestampTags",
"inferTimezoneFromTimeStamp",
"numericTags",
"useMWG",
"struct",
Expand Down Expand Up @@ -324,7 +326,9 @@ export class ReadTask extends ExifToolTask<Tags> {
: // not applicable:
undefined) ??
// This is a last-ditch estimation heuristic:
extractTzOffsetFromUTCOffset(this.#rawDegrouped)
extractTzOffsetFromUTCOffset(this.#rawDegrouped) ??
// No, really, this is the even worse than UTC offset heuristics:
extractTzOffsetFromTimeStamp(this.#rawDegrouped, this.options)

if (result != null) {
this.#tz = result.tz
Expand Down
30 changes: 30 additions & 0 deletions src/Timezones.ts
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,36 @@ export function extractTzOffsetFromDatestamps(
return
}

export function extractTzOffsetFromTimeStamp(
t: Tags,
opts: Partial<
Pick<
ExifToolOptions,
"inferTimezoneFromTimeStamp" | "inferTimezoneFromDatestampTags"
>
>
): Maybe<TzSrc> {
if (opts?.inferTimezoneFromTimeStamp !== true) return
const ts = ExifDateTime.from(t.TimeStamp)
if (ts == null) return
for (const tagName of opts.inferTimezoneFromDatestampTags ?? []) {
const ea = ExifDateTime.from(t[tagName] as any)
if (ea == null) continue
if (ea.zone != null) {
return { tz: ea.zone, src: tagName }
}
const deltaMinutes = Math.floor(
(ea.toEpochSeconds("UTC") - ts.toEpochSeconds()) / 60
)
const likelyOffsetZone = inferLikelyOffsetMinutes(deltaMinutes)
const tz = offsetMinutesToZoneName(likelyOffsetZone)
if (tz != null) {
return { tz, src: "offset between " + tagName + " and TimeStamp" }
}
}
return
}

// timezone offsets may be on a 15 minute boundary, but if GPS acquisition is
// old, this can be spurious. We get less mistakes with a larger multiple, so
// we're using 30 minutes instead of 15. See
Expand Down

0 comments on commit 6b6e83b

Please sign in to comment.