Pick up location from C2PA or Exif

This commit is contained in:
N-Pex 2025-11-03 15:27:42 +01:00
parent d6c8146096
commit 64b14cef40
2 changed files with 197 additions and 113 deletions

View file

@ -88,43 +88,18 @@ const updateDetails = () => {
});
}
const exif = props.proof?.integrity?.exif;
if (exif) {
const getSimpleValue = (key: string): string | undefined => {
return exif ? (exif[key] as string)?.replace(/^"(.+(?="$))"$/, "$1") : undefined;
};
const toDegrees = (dms: string | undefined, direction: string) => {
if (!dms || dms.length == 0) return undefined;
var parts = dms.split(/deg|min|sec/);
var d = parts[0];
var m = parts[1];
var s = parts[2];
var deg = (Number(d) + Number(m) / 60 + Number(s) / 3600).toFixed(6);
if (direction == "S" || direction == "W") {
deg = "-" + deg;
}
return deg;
};
// Location
try {
const lat = toDegrees(getSimpleValue("GPSLatitude"), getSimpleValue("GPSLatitudeRef") ?? "");
const lon = toDegrees(getSimpleValue("GPSLongitude"), getSimpleValue("GPSLongitudeRef") ?? "");
if (lat && lon && lat.length > 0 && lon.length > 0) {
const location = lat + " " + lon;
const link =
"https://www.google.com/maps/search/?api=1&query=" + encodeURIComponent(lat) + "," + encodeURIComponent(lon);
d.push({
icon: "$vuetify.icons.ic_exif_location",
title: t("file_mode.cc_location"),
value: location,
link: link,
});
}
} catch (error) { }
if (props.metadata?.location) {
const lat = props.metadata.location.latitude;
const lon = props.metadata.location.longitude;
const location = lat + " " + lon;
const link = "https://www.google.com/maps/search/?api=1&query=" + encodeURIComponent(lat) + "," + encodeURIComponent(lon);
d.push({
icon: "$vuetify.icons.ic_exif_location",
title: t("file_mode.cc_location"),
value: location,
link: link,
});
}
details.value = d;
};

View file

@ -62,13 +62,19 @@ export type Proof = {
ai?: { inferenceResult?: AIInferenceResult };
};
export type MediaMetadataGenerator = "unknown" | "camera" | "screenshot" | "ai";
export type MediaMetadataGenerator = "unknown" | "camera" | "screenshot" | "ai";
export type MediaMetadataPropertySource = "c2pa" | "exif" | "metadata";
export type MediaMetadataEdit = {
editor: string;
date?: Date;
}
};
export type MediaMetadataLocation = {
latitude: string;
longitude: string;
source: MediaMetadataPropertySource;
};
export type MediaInterventionFlags = {
creationDate?: Date;
@ -76,7 +82,7 @@ export type MediaInterventionFlags = {
modified?: boolean;
containsC2PA?: boolean;
containsEXIF?: boolean;
}
};
export type MediaMetadata = {
device?: string;
@ -87,6 +93,7 @@ export type MediaMetadata = {
edits?: MediaMetadataEdit[];
containsC2PA?: boolean;
containsEXIF?: boolean;
location?: MediaMetadataLocation;
};
type FlagMatchRule = {
@ -98,7 +105,7 @@ type FlagMatchRule = {
type FlagMatchRulePathSegment = {
object: any;
path: string;
}
};
type FlagMatchRuleValue = {
path: FlagMatchRulePathSegment[];
@ -114,51 +121,92 @@ type FlagMatchInfo = {
type FlagValue = {
path: string;
transform?: (value: any, match: FlagMatchRuleValue) => any;
}
};
const pathsC2PASource = (): FlagValue[] => {
return [
{path: "integrity/c2pa/manifest_info/manifests[]/assertions[label=stds.exif]/data/exif:Make", transform: getExifMakeModelPrefixed },
{path: "integrity/c2pa/manifest_info/manifests[]/assertions[label=stds.exif]/data/exif:Model", transform: getExifMakeModelPrefixed },
{path: "integrity/c2pa/manifest_info/manifests[]/assertions[label=c2pa.actions|c2pa.actions.v2]/data/actions[action=c2pa.created]/softwareAgent/name"},
{path: "integrity/c2pa/manifest_info/manifests[]/assertions[label=c2pa.actions|c2pa.actions.v2]/data/actions[action=c2pa.created]/softwareAgent"},
{path: "integrity/c2pa/manifest_info/manifests[]/assertions[label=c2pa.actions|c2pa.actions.v2]/data/actions[action=c2pa.created]/../../../claim_generator"}
{
path: "integrity/c2pa/manifest_info/manifests[]/assertions[label=stds.exif]/data/exif:Make",
transform: getExifMakeModelPrefixed,
},
{
path: "integrity/c2pa/manifest_info/manifests[]/assertions[label=stds.exif]/data/exif:Model",
transform: getExifMakeModelPrefixed,
},
{
path: "integrity/c2pa/manifest_info/manifests[]/assertions[label=c2pa.actions|c2pa.actions.v2]/data/actions[action=c2pa.created]/softwareAgent/name",
},
{
path: "integrity/c2pa/manifest_info/manifests[]/assertions[label=c2pa.actions|c2pa.actions.v2]/data/actions[action=c2pa.created]/softwareAgent",
},
{
path: "integrity/c2pa/manifest_info/manifests[]/assertions[label=c2pa.actions|c2pa.actions.v2]/data/actions[action=c2pa.created]/../../../claim_generator",
},
];
};
const pathsExifSource = (): FlagValue[] => {
return [
{path: "integrity/exif/Make", transform: getExifMakeModelNoPrefix },
{path: "integrity/exif/Model", transform: getExifMakeModelNoPrefix }
{ path: "integrity/exif/Make", transform: getExifMakeModelNoPrefix },
{ path: "integrity/exif/Model", transform: getExifMakeModelNoPrefix },
];
};
const pathsC2PACreationDate = (): FlagValue[] => {
return [
{path: "integrity/c2pa/manifest_info/manifests[]/assertions[label=c2pa.actions|c2pa.actions.v2]/data/actions[action=c2pa.created]/when"},
{path: "integrity/c2pa/manifest_info/manifests[]/assertions[label=c2pa.actions|c2pa.actions.v2]/data/actions[action=c2pa.created]/../../metadata/dateTime"},
{path: "integrity/c2pa/manifest_info/manifests[]/assertions[label=c2pa.actions|c2pa.actions.v2]/data/actions[action=c2pa.created]/../../../signature_info/time"},
{path: "integrity/c2pa/manifest_info/manifests[]/assertions[label=stds.exif]/data/exif:DateTimeOriginal", transform: (value, match) => {
{
path: "integrity/c2pa/manifest_info/manifests[]/assertions[label=c2pa.actions|c2pa.actions.v2]/data/actions[action=c2pa.created]/when",
},
{
path: "integrity/c2pa/manifest_info/manifests[]/assertions[label=c2pa.actions|c2pa.actions.v2]/data/actions[action=c2pa.created]/../../metadata/dateTime",
},
{
path: "integrity/c2pa/manifest_info/manifests[]/assertions[label=c2pa.actions|c2pa.actions.v2]/data/actions[action=c2pa.created]/../../../signature_info/time",
},
{
path: "integrity/c2pa/manifest_info/manifests[]/assertions[label=stds.exif]/data/exif:DateTimeOriginal",
transform: (value, match) => {
// Check if we have a timestamp offset
let offset = extractFlagValues("../exif:OffsetTimeOriginal", match.path)?.at(0)?.value;
return dayjs(Date.parse(value + (offset ? offset : ""))).format("lll");
}}
},
},
];
}
};
const pathsC2PACamera = (): FlagValue[] => {
return [
{path: "integrity/c2pa/manifest_info/manifests[]/assertions[label=stds.exif]/data{exif:SceneType=1|directly photographed image}"},
{path: "integrity/c2pa/manifest_info/manifests[]/assertions[label=stds.exif]/data/exif:LensMake"},
{path: "integrity/c2pa/manifest_info/manifests[]/assertions[label=stds.exif]/data/exif:LensModel"},
{
path: "integrity/c2pa/manifest_info/manifests[]/assertions[label=stds.exif]/data{exif:SceneType=1|directly photographed image}",
},
{ path: "integrity/c2pa/manifest_info/manifests[]/assertions[label=stds.exif]/data/exif:LensMake" },
{ path: "integrity/c2pa/manifest_info/manifests[]/assertions[label=stds.exif]/data/exif:LensModel" },
];
};
const pathsExifCamera = (): FlagValue[] => {
return [
{path: "integrity/exif{SceneType=1|directly photographed image}"},
{path: "integrity/exif/LensMake"},
{path: "integrity/exif/LensModel"},
{ path: "integrity/exif{SceneType=1|directly photographed image}" },
{ path: "integrity/exif/LensMake" },
{ path: "integrity/exif/LensModel" },
];
};
const pathsC2PALocation = (): FlagValue[] => {
return [
{ path: "integrity/c2pa/manifest_info/manifests[]/assertions[label=stds.exif]/data/GPSLatitude" },
{ path: "integrity/c2pa/manifest_info/manifests[]/assertions[label=stds.exif]/data/GPSLongitude" },
{ path: "integrity/c2pa/manifest_info/manifests[]/assertions[label=stds.exif]/data/GPSLatitudeRef" },
{ path: "integrity/c2pa/manifest_info/manifests[]/assertions[label=stds.exif]/data/GPSLongitudeRef" },
];
};
const pathsExifLocation = (): FlagValue[] => {
return [
{ path: "integrity/exif/GPSLatitude" },
{ path: "integrity/exif/GPSLongitude" },
{ path: "integrity/exif/GPSLatitudeRef" },
{ path: "integrity/exif/GPSLongitudeRef" },
];
};
@ -166,47 +214,62 @@ const getExifValue = (val: string): string => {
return val.replace(/^"(.+(?="$))"$/, "$1");
};
const toDegrees = (dms: string | undefined, direction: string) => {
if (!dms || dms.length == 0) return undefined;
var parts = dms.split(/deg|min|sec/);
var d = parts[0];
var m = parts[1];
var s = parts[2];
var deg = (Number(d) + Number(m) / 60 + Number(s) / 3600).toFixed(6);
if (direction == "S" || direction == "W") {
deg = "-" + deg;
}
return deg;
};
const pathsExifCreationDate = (): FlagValue[] => {
return [
{path: "integrity/exif/DateTimeOriginal", transform: (value, match) => {
{
path: "integrity/exif/DateTimeOriginal",
transform: (value, match) => {
let dateTimeOriginal = getExifValue(value);
// Check if we have a timestamp offset
let offset = extractFlagValues("../OffsetTimeOriginal", match.path)?.at(0)?.value as string;
return dayjs(Date.parse(dateTimeOriginal + (offset ? getExifValue(offset) : ""))).format("lll");
}}
},
},
];
}
};
const getExifMakeModelNoPrefix = (ignoredvalue: any, match: FlagMatchRuleValue): string => {
return getExifMakeModel(match, "");
}
};
const getExifMakeModelPrefixed = (ignoredvalue: any, match: FlagMatchRuleValue): string => {
return getExifMakeModel(match, "exif:");
}
};
const getExifMakeModel = (match: FlagMatchRuleValue, prefix: string): string => {
// Make and model
let makeAndModel = "";
const make = extractFlagValues(`../${prefix}Make`, match.path)?.at(0)?.value as string;
const model = extractFlagValues(`../${prefix}Model`, match.path)?.at(0)?.value as string;
if (make) {
makeAndModel += getExifValue(make);
// Make and model
let makeAndModel = "";
const make = extractFlagValues(`../${prefix}Make`, match.path)?.at(0)?.value as string;
const model = extractFlagValues(`../${prefix}Model`, match.path)?.at(0)?.value as string;
if (make) {
makeAndModel += getExifValue(make);
}
if (model) {
if (makeAndModel.length > 0) {
makeAndModel += ", ";
}
if (model) {
if (makeAndModel.length > 0) {
makeAndModel += ", ";
}
makeAndModel += getExifValue(model);
}
return makeAndModel;
}
makeAndModel += getExifValue(model);
}
return makeAndModel;
};
const ruleScreenshotMeta = (): FlagMatchRule[] => {
return [
{
field:
"name",
field: "name",
match: ["screenshot"],
description: "Screen capture",
},
@ -260,10 +323,7 @@ const matchFlag = (rules: FlagMatchRule[], path: FlagMatchRulePathSegment[]) =>
};
const extractFlagValues = (flagPath: string, path: FlagMatchRulePathSegment[]): FlagMatchRuleValue[] => {
const getValues = (
keys: string[],
path: FlagMatchRulePathSegment[]
): FlagMatchRuleValue[] | undefined => {
const getValues = (keys: string[], path: FlagMatchRulePathSegment[]): FlagMatchRuleValue[] | undefined => {
if (keys.length == 0 || path.length == 0) return undefined;
const o = path[0].object;
@ -289,7 +349,13 @@ const extractFlagValues = (flagPath: string, path: FlagMatchRulePathSegment[]):
}
let nextObject = o[key];
let omatches: any[] = nextObject ? (Array.isArray(nextObject) ? nextObject : hasBracket ? Object.values(nextObject) : [nextObject]) : [];
let omatches: any[] = nextObject
? Array.isArray(nextObject)
? nextObject
: hasBracket
? Object.values(nextObject)
: [nextObject]
: [];
// Any constraints controlling what array object(s) to consider?
if (optionalConstraint) {
@ -321,7 +387,7 @@ const extractFlagValues = (flagPath: string, path: FlagMatchRulePathSegment[]):
return { value: oin, path: [{ object: oin, path: key + (omatches.length > 1 ? `[${i}]` : "") }, ...path] };
});
} else if (omatches.length == 1) {
return getValues(keys.slice(1), [{object: omatches[0], path: key}, ...path]);
return getValues(keys.slice(1), [{ object: omatches[0], path: key }, ...path]);
} else {
return omatches.reduce((res: FlagMatchRuleValue[] | undefined, oin: any, i: number) => {
let matches = getValues(keys.slice(1), [{ object: oin, path: key + "[" + i + "]" }, ...path]);
@ -354,20 +420,19 @@ const getFirstWithData = (flagValues: FlagValue[], path: FlagMatchRulePathSegmen
for (let idx = 0; idx < flagValues.length; idx++) {
const result = extractFlagValues(flagValues[idx].path, path);
if (result.length > 0) {
first = result[0]
first = result[0];
transform = flagValues[idx].transform;
break;
}
}
if (first && first.value as string) {
if (first && (first.value as string)) {
try {
let val = first.value as string;
if (val && transform) {
val = transform(val, first);
}
return val;
} catch (error) {
}
} catch (error) {}
}
return undefined;
};
@ -382,21 +447,33 @@ const getFirstWithDataAsDate = (flagValues: FlagValue[], path: FlagMatchRulePath
date = utils.parseExifDate(val);
}
return date;
} catch (error) {
}
} catch (error) {}
}
return undefined;
};
const getMultiple = (flagValues: FlagValue[], path: FlagMatchRulePathSegment[]): (string | undefined)[] => {
let results: (string | undefined)[] = new Array(flagValues.length);
for (let idx = 0; idx < flagValues.length; idx++) {
const result = extractFlagValues(flagValues[idx].path, path);
if (result.length > 0) {
results[idx] = result[0].value as string;
} else {
results[idx] = undefined;
}
}
return results;
};
export const mediaMetadataToMediaInterventionFlags = (mediaMetadata: MediaMetadata): MediaInterventionFlags => {
return {
creationDate: mediaMetadata.creationDate,
generator: mediaMetadata.generator,
modified: mediaMetadata.edits && mediaMetadata.edits.length > 0,
containsC2PA: mediaMetadata.containsC2PA,
containsEXIF: mediaMetadata.containsEXIF
}
}
containsEXIF: mediaMetadata.containsEXIF,
};
};
export const extractMediaMetadata = (proof?: Proof): MediaMetadata | undefined => {
if (!proof) return undefined;
@ -410,7 +487,7 @@ export const extractMediaMetadata = (proof?: Proof): MediaMetadata | undefined =
valid = results.failure.length == 0 && results.success.length > 0;
}
const rootMatchPath = [{object: proof, path: ""}];
const rootMatchPath = [{ object: proof, path: "" }];
let source: string | undefined = undefined;
let dateCreated: Date | undefined = undefined;
@ -431,18 +508,28 @@ export const extractMediaMetadata = (proof?: Proof): MediaMetadata | undefined =
}
}
let generator: MediaMetadataGenerator = "unknown";
let generatorSource: MediaMetadataPropertySource | undefined = undefined;
let digitalSourceType = extractFlagValues("integrity/c2pa/manifest_info/manifests[]/assertions[label=c2pa.actions|c2pa.actions.v2]/data/actions[action=c2pa.created]/digitalSourceType", rootMatchPath)?.at(0)?.value as string;
let digitalSourceType = extractFlagValues(
"integrity/c2pa/manifest_info/manifests[]/assertions[label=c2pa.actions|c2pa.actions.v2]/data/actions[action=c2pa.created]/digitalSourceType",
rootMatchPath
)?.at(0)?.value as string;
if ([C2PASourceTypeScreenCapture].includes(digitalSourceType)) {
generator = "screenshot";
generatorSource = "c2pa";
} else if ([C2PASourceTypeDigitalCapture, C2PASourceTypeComputationalCapture, C2PASourceTypeCompositeCapture].includes(digitalSourceType)) {
} else if (
[C2PASourceTypeDigitalCapture, C2PASourceTypeComputationalCapture, C2PASourceTypeCompositeCapture].includes(
digitalSourceType
)
) {
generator = "camera";
generatorSource = "c2pa";
} else if ([C2PASourceTypeTrainedAlgorithmicMedia, C2PASourceTypeCompositeWithTrainedAlgorithmicMedia].includes(digitalSourceType)) {
} else if (
[C2PASourceTypeTrainedAlgorithmicMedia, C2PASourceTypeCompositeWithTrainedAlgorithmicMedia].includes(
digitalSourceType
)
) {
generator = "ai";
generatorSource = "c2pa";
} else if (getFirstWithData(pathsC2PACamera(), rootMatchPath)) {
@ -468,20 +555,41 @@ export const extractMediaMetadata = (proof?: Proof): MediaMetadata | undefined =
if (c2paEdits.length > 0) {
edits = c2paEdits.map((edit) => {
return {
editor: getFirstWithData([
{path: "softwareAgent/name"},
{path: "softwareAgent"},
{path: "../../../claim_generator"}
], edit.path) ?? "",
date: getFirstWithDataAsDate([
{path: "when"},
{path: "../../metadata/dateTime"},
{path: "../../../signature_info/time"}
], edit.path)
}
editor:
getFirstWithData(
[{ path: "softwareAgent/name" }, { path: "softwareAgent" }, { path: "../../../claim_generator" }],
edit.path
) ?? "",
date: getFirstWithDataAsDate(
[{ path: "when" }, { path: "../../metadata/dateTime" }, { path: "../../../signature_info/time" }],
edit.path
),
};
});
}
// Location
let location: MediaMetadataLocation | undefined = undefined;
let locationSource: MediaMetadataPropertySource = "c2pa"
let [lat, lon, latref, lonref] = getMultiple(pathsC2PALocation(), rootMatchPath);
if (!lat || !lon) {
[lat, lon, latref, lonref] = getMultiple(pathsExifLocation(), rootMatchPath);
locationSource = "exif"
}
if (lat && lon) {
try {
const latitude = toDegrees(getExifValue(lat), getExifValue(latref ?? ""));
const longitude = toDegrees(getExifValue(lon), getExifValue(lonref ?? ""));
if (latitude && longitude && latitude.length > 0 && longitude.length > 0) {
location = {
latitude,
longitude,
source: "exif",
};
}
} catch (error) {}
}
// Do we have any data? Else, return "undefined", we don't just want to send an object with all defaults.
if (source === undefined && dateCreated === undefined && generator === "unknown" && (!edits || edits.length == 0)) {
return undefined;
@ -495,7 +603,8 @@ export const extractMediaMetadata = (proof?: Proof): MediaMetadata | undefined =
generatorSource: generatorSource,
edits: edits,
containsC2PA: proof.integrity?.c2pa !== undefined,
containsEXIF: proof.integrity?.exif !== undefined
containsEXIF: proof.integrity?.exif !== undefined,
location: location,
};
return flags;
} catch (error) {}