Cleanup metadata code and detect Mac screenshots via UserData Exif field.

This commit is contained in:
N-Pex 2025-11-12 15:20:31 +01:00
parent c7e5375927
commit f87446c6a4

View file

@ -121,6 +121,8 @@ type FlagMatchInfo = {
type FlagValue = {
path: string;
transform?: (value: any, match: FlagMatchRuleValue) => any;
matches?: string[];
description?: string; // Not currently used
};
const pathsC2PASource = (): FlagValue[] => {
@ -176,9 +178,7 @@ const pathsC2PACreationDate = (): FlagValue[] => {
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:SceneType", matches: ["^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" },
];
@ -186,12 +186,31 @@ const pathsC2PACamera = (): FlagValue[] => {
const pathsExifCamera = (): FlagValue[] => {
return [
{ path: "integrity/exif{SceneType=1|directly photographed image}" },
{ path: "integrity/exif/SceneType", matches: ["^1$", "^directly photographed image$"] },
{ path: "integrity/exif/LensMake" },
{ path: "integrity/exif/LensModel" },
];
};
const pathsExifScreenshot = (): FlagValue[] => {
return [
{
path: "integrity/exif/UserComment",
transform: exifStringTransform,
matches: ["^Screenshot$"],
},
];
};
const pathsMetaScreenshot = (): FlagValue[] => {
return [
{
path: "name",
matches: ["screenshot"],
}
];
};
const pathsC2PALocation = (): FlagValue[] => {
return [
{ path: "integrity/c2pa/manifest_info/manifests[]/assertions[label=stds.exif]/data/GPSLatitude" },
@ -210,6 +229,30 @@ const pathsExifLocation = (): FlagValue[] => {
];
};
const pathsMetaAI = (): FlagValue[] => {
const knownAIServices = [
"ChatGPT",
"OpenAI-API",
"Adobe Firefly",
"RunwayML",
"Runway AI",
"Google AI",
"Stable Diffusion"
];
return [
{ path: "name", matches: ["^DALL_E_", "^Gen-3"], description: "File name" },
{
path: "integrity/c2pa/manifest_info/manifests[]/claim_generator",
matches: knownAIServices,
description: "C2PA claim generator",
},
{ path: "iptc/Credit", matches: knownAIServices, description: "IPTC Credit" },
{ path: "iptc/Provider", matches: knownAIServices, description: "IPTC Provider" },
{ path: "iptc/ImageSupplier[]", matches: knownAIServices, description: "IPTC ImageSupplier" },
{ path: "iptc/ImageCreator[]", matches: knownAIServices, description: "IPTC ImageCreator" },
];
};
const getExifValue = (val: string): string => {
return val.replace(/^"(.+(?="$))"$/, "$1");
};
@ -249,6 +292,34 @@ const getExifMakeModelPrefixed = (ignoredvalue: any, match: FlagMatchRuleValue):
return getExifMakeModel(match, "exif:");
};
const exifStringTransform = (value: any, match: FlagMatchRuleValue): string => {
try {
const s = value as string;
if (s) {
if (s.toLowerCase().startsWith("0x")) {
if (s.toLowerCase().startsWith("0x554e49434f444500")) {
// Unicode
const buffer = Buffer.from(s.substring(18), "hex");
return buffer.toString("utf-8");
// } else if (s.toLowerCase().startsWith("0x4a49530000000000")) {
// // JIS
// const buffer = Buffer.from(s.substring(18), 'hex');
// return buffer.toString("ascii");
} else if (
s.toLowerCase().startsWith("0x4153434949000000") ||
s.toLowerCase().startsWith("0x0000000000000000")
) {
// Ascii
const buffer = Buffer.from(s.substring(18), "hex");
console.log(buffer.toString("ascii"));
return buffer.toString("ascii");
}
}
}
} catch (e) {}
return value;
};
const getExifMakeModel = (match: FlagMatchRuleValue, prefix: string): string => {
// Make and model
let makeAndModel = "";
@ -266,61 +337,6 @@ const getExifMakeModel = (match: FlagMatchRuleValue, prefix: string): string =>
return makeAndModel;
};
const ruleScreenshotMeta = (): FlagMatchRule[] => {
return [
{
field: "name",
match: ["screenshot"],
description: "Screen capture",
},
];
};
const ruleAiMeta = (): FlagMatchRule[] => {
const knownAIServices = [
"ChatGPT",
"OpenAI-API",
"Adobe Firefly",
"RunwayML",
"Runway AI",
"Google AI",
"Stable Diffusion",
];
return [
{ field: "name", match: ["^DALL_E_", "^Gen-3"], description: "File name" },
{
field: "integrity/c2pa/manifest_info/manifests[]/claim_generator",
match: knownAIServices,
description: "C2PA claim generator",
},
{ field: "iptc/Credit", match: knownAIServices, description: "IPTC Credit" },
{ field: "iptc/Provider", match: knownAIServices, description: "IPTC Provider" },
{ field: "iptc/ImageSupplier[]", match: knownAIServices, description: "IPTC ImageSupplier" },
{ field: "iptc/ImageCreator[]", match: knownAIServices, description: "IPTC ImageCreator" },
];
};
const matchFlag = (rules: FlagMatchRule[], path: FlagMatchRulePathSegment[]) => {
let result = false;
let resultInfo: FlagMatchInfo[] = [];
for (let rule of rules) {
try {
const re: RegExp[] = (!Array.isArray(rule.match) ? [rule.match] : rule.match).map((m) => new RegExp(m, "gi"));
const values = extractFlagValues(rule.field, path);
values.forEach((v) => {
re.forEach((r) => {
if (typeof v.value === "string" && r.test(v.value)) {
result = true;
resultInfo.push({ field: v.path.map((v) => v.path).join("/"), value: v.value, re: r.source });
}
});
});
} catch (e) {
console.error("Invalid RE", e);
}
}
return { result: result, matches: resultInfo };
};
const extractFlagValues = (flagPath: string, path: FlagMatchRulePathSegment[]): FlagMatchRuleValue[] => {
const getValues = (keys: string[], path: FlagMatchRulePathSegment[]): FlagMatchRuleValue[] | undefined => {
@ -335,17 +351,12 @@ const extractFlagValues = (flagPath: string, path: FlagMatchRulePathSegment[]):
const lastBracket = key.lastIndexOf("[");
const hasBracket = key.endsWith("]") && lastBracket > 0;
const lastBrace = key.lastIndexOf("{");
const hasBrace = key.endsWith("}") && lastBrace > 0;
let optionalConstraint: String | undefined = undefined;
if (hasBracket) {
optionalConstraint = key.substring(lastBracket + 1, key.length - 1);
key = key.substring(0, lastBracket);
} else if (hasBrace) {
optionalConstraint = key.substring(lastBrace + 1, key.length - 1);
key = key.substring(0, lastBrace);
}
let nextObject = o[key];
@ -415,25 +426,30 @@ const extractFlagValues = (flagPath: string, path: FlagMatchRulePathSegment[]):
};
const getFirstWithData = (flagValues: FlagValue[], path: FlagMatchRulePathSegment[]): string | undefined => {
let first: FlagMatchRuleValue | undefined = undefined;
let transform: ((value: any, match: FlagMatchRuleValue) => any) | undefined = undefined;
for (let idx = 0; idx < flagValues.length; idx++) {
const result = extractFlagValues(flagValues[idx].path, path);
if (result.length > 0) {
first = result[0];
transform = flagValues[idx].transform;
break;
try {
let first = result[0];
let val: string | undefined = first.value as string;
let transform = flagValues[idx].transform;
if (val && transform) {
val = transform(val, first);
}
if (val && flagValues[idx].matches) {
if (!flagValues[idx].matches!.some((m) => {
const re = new RegExp(m, "gi");
return re.test(val!);
})) {
val = undefined;
}
}
if (val) {
return val;
}
} catch (error) {}
}
}
if (first && (first.value as string)) {
try {
let val = first.value as string;
if (val && transform) {
val = transform(val, first);
}
return val;
} catch (error) {}
}
return undefined;
};
@ -538,10 +554,13 @@ export const extractMediaMetadata = (proof?: Proof): MediaMetadata | undefined =
} else if (getFirstWithData(pathsExifCamera(), rootMatchPath)) {
generator = "camera";
generatorSource = "exif";
} else if (matchFlag(ruleScreenshotMeta(), rootMatchPath).result) {
} else if (getFirstWithData(pathsExifScreenshot(), rootMatchPath)) {
generator = "screenshot";
generatorSource = "exif";
} else if (getFirstWithData(pathsMetaScreenshot(), rootMatchPath)) {
generator = "screenshot";
generatorSource = "metadata";
} else if (matchFlag(ruleAiMeta(), rootMatchPath).result) {
} else if (getFirstWithData(pathsMetaAI(), rootMatchPath)) {
generator = "ai";
generatorSource = "metadata";
}