diff --git a/src/components/content-credentials/C2PAInfo.vue b/src/components/content-credentials/C2PAInfo.vue index bf0ba70..68208b6 100644 --- a/src/components/content-credentials/C2PAInfo.vue +++ b/src/components/content-credentials/C2PAInfo.vue @@ -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; }; diff --git a/src/models/proof.ts b/src/models/proof.ts index 4081c4c..c2d4aef 100644 --- a/src/models/proof.ts +++ b/src/models/proof.ts @@ -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) {}