(null);
+ const extractionId = useRef(0);
+ const { toast } = useToast();
+
+ const extractMetadata = useCallback(async (file: File) => {
+ const currentExtractionId = ++extractionId.current;
+ setIsExtracting(true);
+ setError(null);
+ setMetadata(null);
+ setC2paManifest(null);
+ setC2paError(null);
+ setHasProcessed(false);
+
+ try {
+ const [result, pngText, c2paResult] = await Promise.all([
+ parse(file, true),
+ extractPngTextMetadata(file),
+ readC2paManifest(file)
+ .then(manifest => ({ manifest }))
+ .catch(cause => ({
+ error:
+ cause instanceof Error
+ ? cause.message
+ : "Unknown C2PA verification error",
+ })),
+ ]);
+ if (currentExtractionId !== extractionId.current) return;
+
+ const manifest = "manifest" in c2paResult ? c2paResult.manifest : null;
+ setC2paManifest(manifest);
+ setC2paError("error" in c2paResult ? c2paResult.error : null);
+ setMetadata(
+ Object.assign(
+ {},
+ result && typeof result === "object"
+ ? Object.fromEntries(Object.entries(result))
+ : {},
+ pngText,
+ manifest ? { C2PA: manifest } : {}
+ )
+ );
+ setHasProcessed(true);
+ } catch (cause) {
+ if (currentExtractionId !== extractionId.current) return;
+ setError(
+ cause instanceof Error
+ ? `Could not read this image: ${cause.message}`
+ : "Could not read this image. The format may not be supported or the file may be invalid."
+ );
+ } finally {
+ if (currentExtractionId === extractionId.current) {
+ setIsExtracting(false);
+ }
+ }
+ }, []);
+
+ useEffect(() => {
+ if (autoProcess && imageFile) {
+ void extractMetadata(imageFile);
+ }
+ }, [autoProcess, extractMetadata, imageFile]);
+
+ useEffect(
+ () => () => {
+ extractionId.current += 1;
+ },
+ []
+ );
+
+ const handleFile = (file: File | undefined) => {
+ if (!file) return;
+ extractionId.current += 1;
+ setIsExtracting(false);
+ setError(null);
+ setMetadata(null);
+ setC2paManifest(null);
+ setC2paError(null);
+ setHasProcessed(false);
+ setImageFile(file);
+ };
+
+ const handleClear = () => {
+ extractionId.current += 1;
+ setImageFile(null);
+ setMetadata(null);
+ setC2paManifest(null);
+ setC2paError(null);
+ setHasProcessed(false);
+ setIsExtracting(false);
+ setError(null);
+ if (fileInputRef.current) fileInputRef.current.value = "";
+ };
+
+ const copyMetadata = async () => {
+ if (!metadata) return;
+ try {
+ await navigator.clipboard.writeText(stringifyMetadata(metadata));
+ toast({
+ title: "Metadata copied",
+ description: "Image metadata copied to the clipboard.",
+ });
+ } catch (cause) {
+ console.error("Failed to copy image metadata:", cause);
+ toast({
+ title: "Copy failed",
+ description: "Could not copy metadata to the clipboard.",
+ variant: "destructive",
+ });
+ }
+ };
+
+ const metadataText = metadata ? stringifyMetadata(metadata) : "";
+ const hasMetadata = metadata !== null && Object.keys(metadata).length > 0;
+ const aiIndicators = metadata ? findImageAiIndicators(metadata) : [];
+ const c2paSourceTypes = c2paManifest
+ ? findC2paDigitalSourceTypes(c2paManifest)
+ : [];
+ const aiSourceTypes = c2paSourceTypes.filter(sourceType =>
+ /(?:trainedAlgorithmicMedia|compositeWithTrainedAlgorithmicMedia|algorithmicallyEnhanced)$/i.test(
+ sourceType
+ )
+ );
+
+ const renderMetadataContent = () => {
+ if (isExtracting) {
+ return (
+
+ Reading image metadata locally...
+
+ );
+ }
+ if (hasMetadata) {
+ return (
+
+ {metadataText}
+
+ );
+ }
+ return (
+
+ No embedded metadata was found in this image.
+
+ );
+ };
+
+ const renderOriginIndicators = () => {
+ if (isExtracting) {
+ return (
+
+ Checking embedded metadata...
+
+ );
+ }
+ if (aiIndicators.length > 0) {
+ return (
+
+
+ AI-generation indicators were found in this file's metadata.
+
+
+ {aiIndicators.map((indicator, index) => (
+
+ {indicator.field} :{" "}
+ {indicator.evidence}
+
+ ))}
+
+
+ );
+ }
+ return (
+
+ No common AI-generation indicators were found in the metadata. This does
+ not prove the image was not AI-generated; metadata can be missing,
+ edited, or removed.
+
+ );
+ };
+
+ const renderC2paContent = () => {
+ if (isExtracting) {
+ return (
+
+ Checking for C2PA Content Credentials...
+
+ );
+ }
+ if (c2paError) {
+ return (
+
+ Could not inspect C2PA credentials locally: {c2paError}
+
+ );
+ }
+ if (!c2paManifest) {
+ return (
+
+ No embedded C2PA Content Credentials were found.
+
+ );
+ }
+
+ return (
+
+
+ C2PA manifest found. Validation state:{" "}
+ {c2paManifest.validation_state ?? "not reported"} .
+
+ {aiSourceTypes.length > 0 ? (
+
+ The manifest asserts AI-generated or AI-edited content:
+
+ {aiSourceTypes.map(sourceType => (
+
+ {sourceType}
+
+ ))}
+
+
+ ) : (
+
+ The manifest contains no recognized AI-source type. That does not
+ establish that the image was not AI-generated.
+
+ )}
+ {c2paManifest.validation_status?.length ? (
+
+ {c2paManifest.validation_status.map((status, index) => (
+
+ {status.code}
+ {status.explanation ? `: ${status.explanation}` : ""}
+
+ ))}
+
+ ) : null}
+
+ Validation was performed locally. Remote manifest and
+ certificate-status lookups are disabled.
+
+
+ );
+ };
+
+ return (
+
+
+
+ Image Metadata
+ {tool?.shortcut ? : null}
+
+
+
+ Read EXIF, GPS, camera, and other embedded image details
+
+
+
+
+
+
+ Your image stays on this device. Metadata can include private
+ information such as GPS coordinates. SynthID watermark detection is not
+ available in this offline tool.
+
+
+ {error ? (
+
+
+ {error}
+
+
+ ) : null}
+
+
+
+ imageFile && void extractMetadata(imageFile)}
+ disabled={!imageFile || isExtracting}
+ data-testid="extract-metadata"
+ >
+
+ {isExtracting ? "Extracting..." : "Extract Metadata"}
+
+
+
+
+ Auto Process
+
+
+
+
+
+
+
+
+
+
+ Choose an image
+
+
+ event.preventDefault()}
+ onDrop={event => {
+ event.preventDefault();
+ handleFile(event.dataTransfer.files[0]);
+ }}
+ className="flex min-h-36 cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-slate-300 p-6 text-center transition-colors hover:border-primary dark:border-slate-700"
+ >
+
+
+ {imageFile ? "Choose a different image" : "Choose an image"}
+
+
+ or drag and drop an image here
+
+ {
+ handleFile(event.currentTarget.files?.[0]);
+ event.currentTarget.value = "";
+ }}
+ data-testid="image-file-input"
+ />
+
+
+ {imageFile ? (
+
+
+ {imageFile.name}
+
+ {formatBytes(imageFile.size)}
+ {imageFile.type ? {imageFile.type} : null}
+
+ ) : null}
+
+
+
+ {hasProcessed || isExtracting ? (
+
+
+ C2PA Content Credentials
+
+ {renderC2paContent()}
+
+ ) : null}
+
+ {hasProcessed || isExtracting ? (
+
+
+ Image Origin Indicators
+
+ {renderOriginIndicators()}
+
+ ) : null}
+
+ {hasProcessed || isExtracting ? (
+
+
+ Extracted Metadata
+ {hasMetadata ? (
+ void copyMetadata()}
+ data-testid="copy-metadata"
+ >
+
+ Copy JSON
+
+ ) : null}
+
+ {renderMetadataContent()}
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/package-lock.json b/package-lock.json
index 065ba4a4..cb2e36c8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -26,6 +26,7 @@
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.38.1",
+ "@contentauth/c2pa-web": "^0.15.4",
"@eslint/js": "^9.33.0",
"@peculiar/x509": "^2.0.0",
"@playwright/test": "^1.57.0",
@@ -57,6 +58,7 @@
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"eslint-plugin-unused-imports": "^4.1.4",
+ "exifr": "^7.1.3",
"express": "^4.21.2",
"js-yaml": "^4.1.0",
"jsbarcode": "^3.12.1",
@@ -803,6 +805,39 @@
"w3c-keyname": "^2.2.4"
}
},
+ "node_modules/@contentauth/c2pa-types": {
+ "version": "0.7.6",
+ "resolved": "https://registry.npmjs.org/@contentauth/c2pa-types/-/c2pa-types-0.7.6.tgz",
+ "integrity": "sha512-C7khzKdmdXilaEHEtkjCMzRzu1js+/Uk+cTdcJtaYAY7+Zce5gfYn3S/hgjg0da4SsvH4kOMVqWwH4ZfnS1kvw=="
+ },
+ "node_modules/@contentauth/c2pa-utilities": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/@contentauth/c2pa-utilities/-/c2pa-utilities-0.3.2.tgz",
+ "integrity": "sha512-ZqFAUdSCID18cpfRWPwRRPG/p0cjSnt06IOmkUuQCGtStGm672ZucNk8xnrpV8dQuZwVW0DZWixgh5HMVQ96ug==",
+ "license": "MIT",
+ "dependencies": {
+ "@contentauth/c2pa-types": "0.7.6",
+ "ts-deepmerge": "^8.0.0"
+ }
+ },
+ "node_modules/@contentauth/c2pa-wasm": {
+ "version": "0.13.3",
+ "resolved": "https://registry.npmjs.org/@contentauth/c2pa-wasm/-/c2pa-wasm-0.13.3.tgz",
+ "integrity": "sha512-nAXRuOZ+I4+o+m69ZI49r12qoda5AxlzL2vCAJJuVdkybebQTUhUT3ruyhhINachKtP+nnQ+iJ40KJnjjZSwnA==",
+ "license": "MIT"
+ },
+ "node_modules/@contentauth/c2pa-web": {
+ "version": "0.15.4",
+ "resolved": "https://registry.npmjs.org/@contentauth/c2pa-web/-/c2pa-web-0.15.4.tgz",
+ "integrity": "sha512-i3bs67iNjovbBoIQwSUzdOQRU5+e/kJPWnIMSmn6tXF1WYWFKR8GhaX3ZyAM8EOLlefoNSmjztQr2wpGvJiCVg==",
+ "license": "MIT",
+ "dependencies": {
+ "@contentauth/c2pa-types": "0.7.6",
+ "@contentauth/c2pa-utilities": "0.3.2",
+ "@contentauth/c2pa-wasm": "0.13.3",
+ "highgain": "^0.1.0"
+ }
+ },
"node_modules/@csstools/color-helpers": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.2.tgz",
@@ -6161,6 +6196,12 @@
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
+ "node_modules/exifr": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/exifr/-/exifr-7.1.3.tgz",
+ "integrity": "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==",
+ "license": "MIT"
+ },
"node_modules/expect-type": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz",
@@ -6813,6 +6854,12 @@
"node": ">= 0.4"
}
},
+ "node_modules/highgain": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/highgain/-/highgain-0.1.0.tgz",
+ "integrity": "sha512-Vw/g2upw4MYLZ9e95k79+idMoPFEKvTQVTce9LCDfI+T6AiG+SdiR4WDKszB1grQy0O+uhxd6cqbdi3VhECdbA==",
+ "license": "ISC"
+ },
"node_modules/html-encoding-sniffer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
@@ -10088,6 +10135,15 @@
"typescript": ">=4.8.4"
}
},
+ "node_modules/ts-deepmerge": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/ts-deepmerge/-/ts-deepmerge-8.0.0.tgz",
+ "integrity": "sha512-133O+10nJmVI8w5xeVZPEv5PIrv7iaUae07wv1aH8XJH95Ur6YIhWAPhPyP1YPlbPS9fCVcNIZTu7m8urRVF0A==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=14.13.1"
+ }
+ },
"node_modules/ts-interface-checker": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
diff --git a/package.json b/package.json
index 92eb909d..9357577d 100644
--- a/package.json
+++ b/package.json
@@ -28,6 +28,7 @@
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.38.1",
+ "@contentauth/c2pa-web": "^0.15.4",
"@eslint/js": "^9.33.0",
"@peculiar/x509": "^2.0.0",
"@playwright/test": "^1.57.0",
@@ -60,6 +61,7 @@
"eslint-plugin-react-refresh": "^0.4.20",
"eslint-plugin-unused-imports": "^4.1.4",
"express": "^4.21.2",
+ "exifr": "^7.1.3",
"js-yaml": "^4.1.0",
"jsbarcode": "^3.12.1",
"jsdom": "^26.1.0",
diff --git a/tests/e2e/menu-toggle.spec.ts b/tests/e2e/menu-toggle.spec.ts
index 56b1a066..f3930e5b 100644
--- a/tests/e2e/menu-toggle.spec.ts
+++ b/tests/e2e/menu-toggle.spec.ts
@@ -34,6 +34,10 @@ test.describe("Menu Toggle Functionality", () => {
await expect(sidebar).toBeVisible();
});
+ test("should not render nested navigation links", async ({ page }) => {
+ await expect(page.locator("a a")).toHaveCount(0);
+ });
+
test("should work with keyboard shortcut Ctrl+M", async ({ page }) => {
// Set desktop viewport
await page.setViewportSize({ width: 1280, height: 720 });
diff --git a/tests/e2e/tools/image-metadata.spec.ts b/tests/e2e/tools/image-metadata.spec.ts
new file mode 100644
index 00000000..0e370501
--- /dev/null
+++ b/tests/e2e/tools/image-metadata.spec.ts
@@ -0,0 +1,93 @@
+import { test, expect } from "@playwright/test";
+import { setupJSErrorCollection, expectNoErrors } from "./utils";
+
+function addPngTextChunk(png: Buffer, keyword: string, text: string): Buffer {
+ const data = Buffer.from(`${keyword}\0${text}`, "latin1");
+ const header = Buffer.alloc(8);
+ header.writeUInt32BE(data.length);
+ header.write("tEXt", 4, "ascii");
+ const crc = Buffer.alloc(4);
+ const iend = png.lastIndexOf(Buffer.from([0, 0, 0, 0, 73, 69, 78, 68]));
+ return Buffer.concat([
+ png.subarray(0, iend),
+ header,
+ data,
+ crc,
+ png.subarray(iend),
+ ]);
+}
+
+test.describe("Image Metadata Tool", () => {
+ test.beforeEach(async ({ page }) => {
+ await setupJSErrorCollection(page);
+ await page.goto("/tools/image-metadata");
+ });
+
+ test("reads metadata from an uploaded image locally", async ({ page }) => {
+ const png = Buffer.from(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/WQ0AAAAASUVORK5CYII=",
+ "base64"
+ );
+
+ await page.getByTestId("image-file-input").setInputFiles({
+ name: "one-pixel.png",
+ mimeType: "image/png",
+ buffer: png,
+ });
+
+ await expect(page.getByText("one-pixel.png")).toBeVisible();
+ await expect(page.getByTestId("image-metadata-output")).toBeVisible();
+ await expect(page.getByTestId("image-metadata-output")).toContainText(
+ "ImageWidth"
+ );
+ await expect(page.getByTestId("no-ai-indicators")).toBeVisible();
+ await expect(page.getByTestId("no-c2pa")).toBeVisible();
+ await expectNoErrors(page);
+ });
+
+ test("can disable automatic extraction and process on demand", async ({
+ page,
+ }) => {
+ await page.getByTestId("auto-process-switch").click();
+ await page.getByTestId("image-file-input").setInputFiles({
+ name: "one-pixel.png",
+ mimeType: "image/png",
+ buffer: Buffer.from(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/WQ0AAAAASUVORK5CYII=",
+ "base64"
+ ),
+ });
+
+ await expect(page.getByTestId("extract-metadata")).toBeEnabled();
+ await page.getByTestId("extract-metadata").click();
+ await expect(page.getByTestId("image-metadata-output")).toBeVisible();
+ await expectNoErrors(page);
+ });
+
+ test("detects Stable Diffusion metadata embedded in PNG text", async ({
+ page,
+ }) => {
+ const png = addPngTextChunk(
+ Buffer.from(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/WQ0AAAAASUVORK5CYII=",
+ "base64"
+ ),
+ "parameters",
+ "Steps: 25, Sampler: Euler a, CFG scale: 7"
+ );
+
+ await page.getByTestId("image-file-input").setInputFiles({
+ name: "generated.png",
+ mimeType: "image/png",
+ buffer: png,
+ });
+
+ await expect(page.getByTestId("ai-indicators-found")).toContainText(
+ "AI-generation indicators were found"
+ );
+ await expect(page.getByTestId("image-metadata-output")).toContainText(
+ "PNG Text.parameters"
+ );
+ await expectNoErrors(page);
+ });
+});
diff --git a/tests/lib/c2pa-metadata.test.ts b/tests/lib/c2pa-metadata.test.ts
new file mode 100644
index 00000000..1a27dfcf
--- /dev/null
+++ b/tests/lib/c2pa-metadata.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, it } from "vitest";
+import { findC2paDigitalSourceTypes } from "../../client/src/lib/c2pa-metadata";
+
+describe("findC2paDigitalSourceTypes", () => {
+ it("reads source types from nested C2PA action assertions", () => {
+ expect(
+ findC2paDigitalSourceTypes({
+ manifests: {
+ active: {
+ assertions: [
+ {
+ label: "c2pa.actions",
+ data: {
+ actions: [
+ {
+ digitalSourceType:
+ "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia",
+ },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ })
+ ).toEqual([
+ "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia",
+ ]);
+ });
+
+ it("returns no source types when the manifest has no source-type assertion", () => {
+ expect(
+ findC2paDigitalSourceTypes({
+ manifests: { active: { assertions: [] } },
+ })
+ ).toEqual([]);
+ });
+});
diff --git a/tests/lib/image-ai-indicators.test.ts b/tests/lib/image-ai-indicators.test.ts
new file mode 100644
index 00000000..c62e2b8f
--- /dev/null
+++ b/tests/lib/image-ai-indicators.test.ts
@@ -0,0 +1,41 @@
+import { describe, expect, it } from "vitest";
+import { findImageAiIndicators } from "../../client/src/lib/image-ai-indicators";
+
+describe("findImageAiIndicators", () => {
+ it("identifies known image generator names in metadata", () => {
+ expect(
+ findImageAiIndicators({
+ Software: "Stable Diffusion WebUI",
+ XMP: { CreatorTool: "Adobe Firefly" },
+ })
+ ).toEqual([
+ { field: "Software", evidence: "Stable Diffusion" },
+ { field: "XMP.CreatorTool", evidence: "Adobe Firefly" },
+ ]);
+ });
+
+ it("identifies generation prompts and diffusion parameters", () => {
+ expect(
+ findImageAiIndicators({
+ parameters: "Steps: 30, Sampler: Euler a, CFG scale: 7",
+ prompt: "A scenic landscape",
+ })
+ ).toEqual([
+ {
+ field: "parameters",
+ evidence: "diffusion-style generation parameters",
+ },
+ { field: "prompt", evidence: "generation prompt/parameters field" },
+ ]);
+ });
+
+ it("does not mistake ordinary camera metadata for AI evidence", () => {
+ expect(
+ findImageAiIndicators({
+ Make: "Canon",
+ Model: "EOS R5",
+ Software: "Adobe Photoshop 25.0",
+ })
+ ).toEqual([]);
+ });
+});
diff --git a/tests/lib/png-text-metadata.test.ts b/tests/lib/png-text-metadata.test.ts
new file mode 100644
index 00000000..8fee84db
--- /dev/null
+++ b/tests/lib/png-text-metadata.test.ts
@@ -0,0 +1,51 @@
+import { Blob } from "node:buffer";
+import { describe, expect, it } from "vitest";
+import { extractPngTextMetadata } from "../../client/src/lib/png-text-metadata";
+
+function makeChunk(type: string, data: Uint8Array): Uint8Array {
+ const chunk = new Uint8Array(data.length + 12);
+ const view = new DataView(chunk.buffer);
+ view.setUint32(0, data.length);
+ chunk.set(new TextEncoder().encode(type), 4);
+ chunk.set(data, 8);
+ return chunk;
+}
+
+function makePng(...chunks: Uint8Array[]): Blob {
+ const signature = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]);
+ return new Blob([signature, ...chunks, makeChunk("IEND", new Uint8Array())]);
+}
+
+describe("extractPngTextMetadata", () => {
+ it("reads uncompressed PNG text chunks", async () => {
+ const text = new TextEncoder().encode(
+ "parameters\0Steps: 25, Sampler: Euler a"
+ );
+ const metadata = await extractPngTextMetadata(
+ makePng(makeChunk("tEXt", text))
+ );
+
+ expect(metadata).toEqual({
+ "PNG Text.parameters": "Steps: 25, Sampler: Euler a",
+ });
+ });
+
+ it("reads uncompressed international PNG text chunks", async () => {
+ const text = new TextEncoder().encode(
+ "prompt\0\0\0en-US\0\0A snowy landscape"
+ );
+ const metadata = await extractPngTextMetadata(
+ makePng(makeChunk("iTXt", text))
+ );
+
+ expect(metadata).toEqual({
+ "PNG Text.prompt": "A snowy landscape",
+ });
+ });
+
+ it("returns no PNG text metadata for other file types", async () => {
+ expect(await extractPngTextMetadata(new Blob(["not a PNG image"]))).toEqual(
+ {}
+ );
+ });
+});