astro-image-exif-loader
Version:
Astro content collection loader for extracting EXIF data from images
256 lines (253 loc) • 7.81 kB
JavaScript
import { z } from "astro/zod";
import { exiftool } from "exiftool-vendored";
import { stat } from "fs/promises";
import { basename, relative, resolve } from "path";
import { glob } from "tinyglobby";
import picomatch from "picomatch";
//#region src/utils.ts
const FILESYSTEM_LEAKY_TAGS = [
"Directory",
"FileName",
"FileModifyDate",
"FileAccessDate",
"FileInodeChangeDate",
"FilePermissions",
"FileType",
"FileTypeExtension",
"MIMEType",
"ExifToolVersion",
"SourceFile"
];
const EXIF_PRESET_MAPPINGS = {
basic: [
"FileSize",
"ImageWidth",
"ImageHeight"
],
camera: [
"Make",
"Model",
"LensModel",
"Lens",
"LensID",
"LensInfo",
"LensSerialNumber",
"SerialNumber",
"BodySerialNumber",
"CameraSerialNumber",
"LensMake",
"MaxAperture",
"MinFocalLength",
"MaxFocalLength"
],
exposure: [
"ISO",
"FNumber",
"ExposureTime",
"ShutterSpeed",
"FocalLength",
"FocalLengthIn35mmFormat",
"Flash",
"WhiteBalance",
"ExposureMode",
"MeteringMode"
],
datetime: [
"DateTimeOriginal",
"CreateDate",
"DateTime"
],
location: [
"GPSLatitude",
"GPSLongitude",
"GPSAltitude",
"Country",
"State",
"City",
"Location",
"Sub-location",
"GPSAreaInformation",
"Country-PrimaryLocationCode",
"Province-State"
],
technical: [
"ColorSpace",
"Orientation",
"Software",
"SceneType",
"SceneCaptureType"
],
metadata: [
"Artist",
"Copyright",
"ImageDescription",
"Keywords",
"Title",
"Subject"
]
};
function toSerializable(value) {
if (value === null || value === void 0) return null;
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
if (value instanceof Date || value && typeof value.toDate === "function") try {
return value instanceof Date ? value.toISOString() : value.toDate().toISOString();
} catch {
return String(value);
}
if (Array.isArray(value)) return value.map(toSerializable);
if (value && typeof value.valueOf === "function") {
const primitiveValue = value.valueOf();
if (typeof primitiveValue === "number" || typeof primitiveValue === "string") return primitiveValue;
}
if (value && typeof value.num === "number" && typeof value.den === "number") return value.num / value.den;
return String(value);
}
function buildImageData(tags, fileName, mtime, fileSize, tagsToExtract, excludeTags, includeRawExif) {
const data = {
fileName,
mtime
};
if (tagsToExtract === null) {
for (const [tagName, tagValue] of Object.entries(tags)) if (tagValue !== null && tagValue !== void 0 && !excludeTags.has(tagName)) data[tagName] = toSerializable(tagValue);
data.FileSize = fileSize;
} else if (tagsToExtract.size > 0) {
for (const tagName of tagsToExtract) if (!excludeTags.has(tagName)) {
const tagValue = tags[tagName];
if (tagValue !== null && tagValue !== void 0) data[tagName] = toSerializable(tagValue);
}
if (tagsToExtract.has("FileSize") && !excludeTags.has("FileSize")) data.FileSize = fileSize;
}
if (includeRawExif) {
data.rawExif = {};
for (const [tagName, tagValue] of Object.entries(tags)) if (tagValue !== null && tagValue !== void 0) data.rawExif[tagName] = toSerializable(tagValue);
}
return data;
}
//#endregion
//#region src/loader.ts
function determineTagsToExtract(presets = [], tags) {
const tagsSet = /* @__PURE__ */ new Set();
for (const preset of presets) if (EXIF_PRESET_MAPPINGS[preset]) for (const tag of EXIF_PRESET_MAPPINGS[preset]) tagsSet.add(tag);
for (const tag of tags) tagsSet.add(tag);
return tagsSet;
}
function createExifLoader(options = {}) {
const { imagesDir = {
pattern: "**/*",
base: "src/content/images"
}, presets = [], tags = [], excludeTags = [], extractAll = false, includeRawExif = false } = options;
const tagsToExtract = extractAll ? null : determineTagsToExtract(presets, tags);
const excludeTagsSet = new Set([...FILESYSTEM_LEAKY_TAGS, ...excludeTags]);
return {
name: "exif-gallery-loader",
load: async ({ store, logger, config, watcher, generateDigest }) => {
const dirCfg = imagesDir;
const basePath = resolve(config.root.pathname, dirCfg.base || "src/content/images");
const patterns = Array.isArray(dirCfg.pattern) ? dirCfg.pattern : [dirCfg.pattern];
logger.info(`Loading images with EXIF data from ${patterns.join(", ")} (base: ${basePath})`);
try {
const files = await glob(patterns, {
cwd: basePath,
expandDirectories: false,
onlyFiles: true
});
if (files.length === 0) {
const patternList = patterns.join(", ");
const msg = [
"No images found for astro-exif loader.",
` base: ${basePath}`,
` pattern(s): ${patternList}`,
"Make sure imagesDir.base is relative to your project root (e.g. \"src/content/images\")",
"and pattern matches your files (e.g. \"**/*\" or \"**/*.{jpg,jpeg,png}\")."
].join("\n");
logger.error(msg);
}
for (const rel of files) {
const abs = resolve(basePath, rel);
await processImage(abs, store, logger, generateDigest, config.root.pathname, tagsToExtract, excludeTagsSet, includeRawExif);
}
} catch (error) {
logger.error(`Error globbing files: ${error.message}`);
}
if (watcher) {
watcher.add(basePath);
const isMatch = picomatch(patterns);
const matches = (changed) => {
const absChanged = resolve(changed);
const absBase = resolve(basePath);
const sep = "/";
if (!(absChanged === absBase || absChanged.startsWith(absBase + sep))) return false;
const rel = relative(basePath, changed);
return isMatch(rel);
};
const onAddOrChange = async (filePath) => {
if (!matches(filePath)) return;
logger.info(`File updated: ${filePath}`);
await processImage(filePath, store, logger, generateDigest, config.root.pathname, tagsToExtract, excludeTagsSet, includeRawExif);
};
watcher.on("add", onAddOrChange);
watcher.on("change", onAddOrChange);
watcher.on("unlink", (filePath) => {
if (!matches(filePath)) return;
const id = basename(filePath);
store.delete(id);
logger.info(`File removed: ${filePath}`);
});
}
}
};
}
async function processImage(filePath, store, logger, generateDigest, rootPath = "", tagsToExtract, excludeTags, includeRawExif) {
try {
const fileNameOnly = basename(filePath);
const id = fileNameOnly;
const stats = await stat(filePath);
const mtime = stats.mtime.toISOString();
const existingEntry = store.get(id);
if (existingEntry && existingEntry.data.mtime === mtime) return;
let tags;
try {
tags = await exiftool.read(filePath);
} catch (error) {
const msg = `EXIF read failed for ${fileNameOnly}: ${error.message}`;
logger.warn(msg);
tags = {};
}
const imageData = buildImageData(tags, fileNameOnly, mtime, stats.size, tagsToExtract, excludeTags, includeRawExif);
const digest = generateDigest(imageData);
const success = store.set({
id,
data: imageData,
digest,
filePath: relative(rootPath, filePath)
});
if (success) logger.info(`Processed ${fileNameOnly}`);
else logger.debug(`Skipped ${fileNameOnly} (no changes)`);
} catch (error) {
logger.error(`Failed to process image ${filePath}: ${error.message}`);
}
}
function exifSchema(zod, _options = {}) {
const scalar = zod.union([
zod.string(),
zod.number(),
zod.boolean()
]);
const val = zod.union([scalar, scalar.array()]).nullable().optional();
const base = zod.object({
fileName: zod.string(),
mtime: zod.string(),
rawExif: zod.record(val).optional()
}).catchall(val);
return base;
}
function defineExifCollection(options = {}) {
const loader = createExifLoader(options);
const schema = exifSchema(z, options);
return {
loader,
schema
};
}
//#endregion
export { createExifLoader, defineExifCollection, exifSchema };