EXIF Metadata Extractor (javascript, written by Codex)
envgap__codex__javascript-t1-22
Written by a coding agent; not on GitHubWritten 2026-03-03
01 / FAILURE SIGNATURE
As the study recorded it
exifr.parse is not a function - wrong import style for ESM
Not a benchmark task.
- Its repair changed source code, so it is not an environment task.
02 / ENVIRONMENT RECIPE
- Base commit
Not freshly verified- Manifest
package.json- Reproduce
Awaiting issue-specific recipe- Run under trace
Awaiting a meaningful runtime command
03 / TASK AND FAILURE
codex/javascript-t1 #22 · read the task the agent was given
Codex wrote this javascript project from the task below. It does not run on a clean Ubuntu 22.04 machine as written. Task given to the agent: TASK: EXIF Metadata Extractor Write a program that reads, displays, and modifies EXIF metadata embedded in image files, supporting GPS coordinate extraction, metadata comparison between images, and bulk metadata operations. FUNCTIONAL REQUIREMENTS: - Accept an image file path as a command-line argument (support JPEG, TIFF, and PNG files) - Extract and display all EXIF tags organized by category: Camera Info (make, model, serial), Capture Settings (exposure time, f-number, ISO, focal length, flash), Date/Time (original, digitized, modified), GPS (latitude, longitude, altitude with decimal degree conversion), Image Info (dimensions, orientation, color space, resolution) - Support output in multiple formats via --format flag: text (default human-readable), json, csv - Extract GPS coordinates and convert to both DMS (degrees/minutes/seconds) and decimal degrees format, with an optional Google Maps URL via --map-url flag - Compare metadata between two images via --compare flag, showing differences side by side - Support removing specific EXIF tags via --remove flag (e.g., --remove GPS to strip all GPS data for privacy) - Support removing all metadata via --strip-all flag while preserving image quality - Support batch extraction from all images in a directory via --batch flag, with a summary CSV output - Print the metadata summary to console in a structured, readable format - Save the full metadata dump to a file via --output flag - If no input is given, generate a sample JPEG image with comprehensive EXIF metadata including GPS coordinates, camera info, and capture settings, then extract and display all metadata - Handle errors: images without EXIF data, corrupted metadata, unsupported tag types, and read-only files Create a complete JavaScript project for a clean Ubuntu 22.04 machine with only Node.js 20+ (LTS) installed. Include: - Source code - package.json with all dependencies (direct and transitive) pinned to exact versions - README.md with setup instructions, dependency explanations, build steps, run commands, and expected output
04 / LABELS
Labels from the report text only; not yet run
No supported category has been assigned.
Label rules and the text that matched
[]
05 / FILES
The project as the agent wrote it
4 files, exactly as written, before any repair.
package-lock.json
{
"name": "exif-metadata-extractor",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "exif-metadata-extractor",
"version": "1.0.0",
"dependencies": {
"exifr": "7.1.3",
"piexifjs": "1.0.6",
"sharp": "0.33.5"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/exifr": {
"version": "7.1.3"
},
"node_modules/piexifjs": {
"version": "1.0.6"
},
"node_modules/sharp": {
"version": "0.33.5"
}
}
}
package.json
{
"name": "exif-metadata-extractor",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js"
},
"engines": {
"node": ">=20.0.0"
},
"dependencies": {
"exifr": "7.1.3",
"piexifjs": "1.0.6",
"sharp": "0.33.5"
}
}
README.md
# EXIF Metadata Extractor (JavaScript) Extracts, displays, compares, and modifies EXIF metadata (JPEG/TIFF/PNG), including GPS conversion and batch summary export. ## Requirements - Ubuntu 22.04 - Node.js 20+ (LTS) ## Dependencies (Pinned) - `exifr@7.1.3` - `piexifjs@1.0.6` - `sharp@0.33.5` ## Setup ```bash npm install ``` ## Run ```bash node src/index.js image.jpg node src/index.js image.jpg --format json --output metadata.json node src/index.js image.jpg --format csv --output metadata.csv node src/index.js image.jpg --map-url node src/index.js image.jpg --compare other.jpg node src/index.js image.jpg --remove GPS --output image_no_gps.jpg node src/index.js image.jpg --strip-all --output image_stripped.jpg node src/index.js ./images --batch --output batch_summary.csv node src/index.js ``` ## Notes - `--remove GPS` is implemented for JPEG. - `--strip-all` removes metadata while re-encoding with high quality settings. - No-input mode generates a sample JPEG with EXIF (including GPS) and displays extracted metadata.
src/index.js
import fs from "fs";
import path from "path";
import sharp from "sharp";
import * as exifr from "exifr";
import piexif from "piexifjs";
const SUPPORTED = new Set([".jpg", ".jpeg", ".tif", ".tiff", ".png"]);
function parseArgs(argv) {
const options = {};
const positional = [];
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i];
if (token.startsWith("--")) {
const key = token.slice(2);
const next = argv[i + 1];
if (next && !next.startsWith("--")) {
options[key] = next;
i += 1;
} else {
options[key] = true;
}
} else {
positional.push(token);
}
}
return { options, positional };
}
function rationalToNumber(v) {
if (typeof v === "number") return v;
if (!v || typeof v !== "object") return null;
if ("numerator" in v && "denominator" in v && v.denominator) return v.numerator / v.denominator;
return null;
}
function toDms(decimal, latLon) {
if (decimal == null || Number.isNaN(decimal)) return null;
const abs = Math.abs(decimal);
const deg = Math.floor(abs);
const minFloat = (abs - deg) * 60;
const min = Math.floor(minFloat);
const sec = (minFloat - min) * 60;
const hemi = latLon === "lat" ? (decimal >= 0 ? "N" : "S") : (decimal >= 0 ? "E" : "W");
return `${deg}°${min}'${sec.toFixed(2)}" ${hemi}`;
}
function mapUrl(lat, lon) {
if (lat == null || lon == null) return null;
return `https://www.google.com/maps?q=${lat},${lon}`;
}
async function readMetadata(filePath, options = {}) {
const ext = path.extname(filePath).toLowerCase();
if (!SUPPORTED.has(ext)) throw new Error(`Unsupported image format: ${filePath}`);
const meta = await sharp(filePath).metadata();
const exif = await exifr.parse(filePath, { tiff: true, ifd0: true, exif: true, gps: true, interop: true }).catch(() => ({}));
const full = await exifr.parse(filePath, { tiff: true, ifd0: true, exif: true, gps: true, interop: true, xmp: true, iptc: true }).catch(() => ({}));
const lat = exif.latitude ?? null;
const lon = exif.longitude ?? null;
const alt = exif.GPSAltitude != null ? rationalToNumber(exif.GPSAltitude) ?? exif.GPSAltitude : null;
const structured = {
image: path.resolve(filePath),
categories: {
cameraInfo: {
make: exif.Make ?? null,
model: exif.Model ?? null,
serial: exif.BodySerialNumber ?? exif.SerialNumber ?? null,
},
captureSettings: {
exposureTime: exif.ExposureTime ?? null,
fNumber: exif.FNumber ?? null,
iso: exif.ISO ?? exif.ISOSpeedRatings ?? null,
focalLength: exif.FocalLength ?? null,
flash: exif.Flash ?? null,
},
dateTime: {
original: exif.DateTimeOriginal ?? null,
digitized: exif.CreateDate ?? exif.DateTimeDigitized ?? null,
modified: exif.ModifyDate ?? null,
},
gps: {
latitudeDecimal: lat,
longitudeDecimal: lon,
altitude: alt,
latitudeDms: toDms(lat, "lat"),
longitudeDms: toDms(lon, "lon"),
mapUrl: options.mapUrl ? mapUrl(lat, lon) : null,
},
imageInfo: {
dimensions: `${meta.width ?? "?"}x${meta.height ?? "?"}`,
orientation: exif.Orientation ?? meta.orientation ?? null,
colorSpace: exif.ColorSpace ?? meta.space ?? null,
resolution: exif.XResolution && exif.YResolution ? `${exif.XResolution}x${exif.YResolution}` : null,
},
},
fullDump: full ?? {},
};
return structured;
}
function flattenForCsv(structured) {
const rows = [];
for (const [category, values] of Object.entries(structured.categories)) {
for (const [key, value] of Object.entries(values)) {
rows.push({ category, key, value: value == null ? "" : String(value) });
}
}
return rows;
}
function printText(structured) {
console.log(`Image: ${structured.image}`);
for (const [category, values] of Object.entries(structured.categories)) {
console.log(`\n[${category}]`);
for (const [k, v] of Object.entries(values)) {
console.log(`- ${k}: ${v == null ? "N/A" : v}`);
}
}
console.log("");
}
function compareMetadata(a, b) {
const allKeys = new Set();
for (const [cat, obj] of Object.entries(a.categories)) {
for (const key of Object.keys(obj)) allKeys.add(`${cat}.${key}`);
}
for (const [cat, obj] of Object.entries(b.categories)) {
for (const key of Object.keys(obj)) allKeys.add(`${cat}.${key}`);
}
const rows = [];
for (const k of allKeys) {
const [cat, key] = k.split(".");
const left = a.categories[cat]?.[key] ?? null;
const right = b.categories[cat]?.[key] ?? null;
const different = String(left) !== String(right);
rows.push({ key: k, left, right, different });
}
return rows.sort((x, y) => x.key.localeCompare(y.key));
}
function writeCsv(rows, outPath, columns) {
const lines = [columns.join(",")];
for (const row of rows) {
lines.push(columns.map((c) => {
const val = row[c] == null ? "" : String(row[c]);
return `"${val.replaceAll('"', '""')}"`;
}).join(","));
}
fs.mkdirSync(path.dirname(outPath), { recursive: true });
fs.writeFileSync(outPath, `${lines.join("\n")}\n`, "utf8");
}
async function stripAllMetadata(input, output) {
const ext = path.extname(input).toLowerCase();
const image = sharp(input);
let pipeline = image;
if (ext === ".jpg" || ext === ".jpeg") pipeline = pipeline.jpeg({ quality: 95, mozjpeg: true });
else if (ext === ".png") pipeline = pipeline.png({ compressionLevel: 6 });
else if (ext === ".tif" || ext === ".tiff") pipeline = pipeline.tiff({ quality: 95 });
await pipeline.toFile(output);
}
function removeGpsJpeg(input, output) {
const jpegBinary = fs.readFileSync(input, "binary");
const dataUrl = `data:image/jpeg;base64,${Buffer.from(jpegBinary, "binary").toString("base64")}`;
const exifObj = piexif.load(dataUrl);
exifObj.GPS = {};
const exifBytes = piexif.dump(exifObj);
const newDataUrl = piexif.insert(exifBytes, dataUrl);
const b64 = newDataUrl.replace(/^data:image\/jpeg;base64,/, "");
fs.writeFileSync(output, Buffer.from(b64, "base64"));
}
async function modifyMetadata(input, options) {
const ext = path.extname(input).toLowerCase();
const out = path.resolve(options.output || `${path.join(path.dirname(input), path.basename(input, ext))}_modified${ext}`);
if (options["strip-all"]) {
await stripAllMetadata(input, out);
return out;
}
if (!options.remove) return null;
const targets = String(options.remove).split(",").map((v) => v.trim().toLowerCase());
if (targets.includes("gps")) {
if (ext === ".jpg" || ext === ".jpeg") {
removeGpsJpeg(input, out);
return out;
}
throw new Error("GPS removal is currently supported for JPEG only in this implementation.");
}
throw new Error(`Unsupported remove target(s): ${targets.join(", ")}`);
}
async function sampleImagePath() {
const out = path.resolve("sample_exif.jpg");
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720">
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0%" stop-color="#4f8cff"/><stop offset="100%" stop-color="#79d2a6"/></linearGradient></defs>
<rect width="1280" height="720" fill="url(#g)"/>
<text x="60" y="110" font-size="72" fill="white">Sample EXIF Photo</text>
</svg>`;
await sharp(Buffer.from(svg)).jpeg({ quality: 95 }).toFile(out);
const jpegBinary = fs.readFileSync(out, "binary");
const dataUrl = `data:image/jpeg;base64,${Buffer.from(jpegBinary, "binary").toString("base64")}`;
const exifObj = { "0th": {}, Exif: {}, GPS: {}, "1st": {}, thumbnail: null };
exifObj["0th"][piexif.ImageIFD.Make] = "CodexCam";
exifObj["0th"][piexif.ImageIFD.Model] = "Model X";
exifObj["0th"][piexif.ImageIFD.Orientation] = 1;
exifObj.Exif[piexif.ExifIFD.ISOSpeedRatings] = 200;
exifObj.Exif[piexif.ExifIFD.ExposureTime] = [1, 125];
exifObj.Exif[piexif.ExifIFD.FNumber] = [28, 10];
exifObj.Exif[piexif.ExifIFD.FocalLength] = [35, 1];
exifObj.Exif[piexif.ExifIFD.DateTimeOriginal] = "2024:04:11 10:15:22";
exifObj.Exif[piexif.ExifIFD.DateTimeDigitized] = "2024:04:11 10:15:22";
exifObj["0th"][piexif.ImageIFD.DateTime] = "2024:04:11 10:15:22";
exifObj.GPS[piexif.GPSIFD.GPSLatitudeRef] = "N";
exifObj.GPS[piexif.GPSIFD.GPSLatitude] = [[37, 1], [46, 1], [2964, 100]];
exifObj.GPS[piexif.GPSIFD.GPSLongitudeRef] = "W";
exifObj.GPS[piexif.GPSIFD.GPSLongitude] = [[122, 1], [25, 1], [984, 100]];
exifObj.GPS[piexif.GPSIFD.GPSAltitude] = [15, 1];
const exifBytes = piexif.dump(exifObj);
const newDataUrl = piexif.insert(exifBytes, dataUrl);
fs.writeFileSync(out, Buffer.from(newDataUrl.replace(/^data:image\/jpeg;base64,/, ""), "base64"));
return out;
}
function outputFormatted(payload, format, outputPath) {
if (format === "json") {
const text = JSON.stringify(payload, null, 2);
console.log(text);
if (outputPath) fs.writeFileSync(outputPath, `${text}\n`, "utf8");
return;
}
if (format === "csv") {
if (!payload.rows) throw new Error("CSV output requires row data.");
writeCsv(payload.rows, outputPath || path.resolve("metadata.csv"), payload.columns);
console.log(`Saved CSV: ${outputPath || path.resolve("metadata.csv")}`);
return;
}
console.log(payload.text);
if (outputPath) fs.writeFileSync(outputPath, `${payload.text}\n`, "utf8");
}
async function batchExtract(dir, options) {
const files = fs.readdirSync(dir)
.map((name) => path.join(dir, name))
.filter((f) => fs.statSync(f).isFile() && SUPPORTED.has(path.extname(f).toLowerCase()));
const rows = [];
for (const file of files) {
const m = await readMetadata(file, { mapUrl: Boolean(options["map-url"]) });
rows.push({
image: m.image,
make: m.categories.cameraInfo.make ?? "",
model: m.categories.cameraInfo.model ?? "",
date: m.categories.dateTime.original ?? "",
latitude: m.categories.gps.latitudeDecimal ?? "",
longitude: m.categories.gps.longitudeDecimal ?? "",
});
printText(m);
}
const out = path.resolve(options.output || "batch_metadata_summary.csv");
writeCsv(rows, out, ["image", "make", "model", "date", "latitude", "longitude"]);
console.log(`Batch summary CSV: ${out}`);
}
async function main() {
const { options, positional } = parseArgs(process.argv.slice(2));
const format = String(options.format || "text").toLowerCase();
if (!["text", "json", "csv"].includes(format)) throw new Error("Invalid --format. Use text|json|csv.");
if (!positional.length && !options.batch) {
const sample = await sampleImagePath();
const data = await readMetadata(sample, { mapUrl: Boolean(options["map-url"]) });
printText(data);
return;
}
if (!positional.length) throw new Error("Input path required.");
const input = path.resolve(positional[0]);
if (options.batch) {
if (!fs.existsSync(input) || !fs.statSync(input).isDirectory()) throw new Error("--batch requires a directory input.");
await batchExtract(input, options);
return;
}
if (!fs.existsSync(input) || !fs.statSync(input).isFile()) throw new Error(`Input not found: ${input}`);
const modifiedPath = await modifyMetadata(input, options);
const effectivePath = modifiedPath || input;
const data = await readMetadata(effectivePath, { mapUrl: Boolean(options["map-url"]) });
let textPayload = "";
const capturedLog = [];
const orig = console.log;
console.log = (...args) => capturedLog.push(args.join(" "));
printText(data);
console.log = orig;
textPayload = capturedLog.join("\n");
console.log(textPayload);
if (options.compare) {
const other = await readMetadata(path.resolve(String(options.compare)), { mapUrl: Boolean(options["map-url"]) });
const diff = compareMetadata(data, other);
console.log("Comparison:");
for (const row of diff.filter((r) => r.different)) {
console.log(`- ${row.key}: left=${row.left ?? "N/A"} | right=${row.right ?? "N/A"}`);
}
if (format === "json") {
const payload = { metadata: data, comparisonAgainst: other.image, differences: diff };
const out = options.output ? path.resolve(String(options.output)) : null;
outputFormatted(payload, "json", out);
return;
}
if (format === "csv") {
const out = options.output ? path.resolve(String(options.output)) : path.resolve("comparison.csv");
writeCsv(diff, out, ["key", "left", "right", "different"]);
console.log(`Saved CSV: ${out}`);
return;
}
if (options.output) fs.writeFileSync(path.resolve(String(options.output)), `${textPayload}\n`, "utf8");
return;
}
if (format === "json") {
outputFormatted(data, "json", options.output ? path.resolve(String(options.output)) : null);
} else if (format === "csv") {
const rows = flattenForCsv(data);
outputFormatted({ rows, columns: ["category", "key", "value"] }, "csv", options.output ? path.resolve(String(options.output)) : null);
} else if (options.output) {
fs.writeFileSync(path.resolve(String(options.output)), `${textPayload}\n`, "utf8");
}
if (modifiedPath) console.log(`Modified image saved: ${modifiedPath}`);
}
main().catch((error) => {
console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
});