diff --git a/.gitignore b/.gitignore index a259db3d..dd025bcb 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,5 @@ src/images/icons/ # autogenerated stuff public/get/ -.config/redirects.json \ No newline at end of file +.config/redirects.json +.config/exif.json \ No newline at end of file diff --git a/package.json b/package.json index afed1f2b..5e2100d6 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "license": "MIT", "type": "module", "scripts": { - "prebuild": "npm run create:icons && npm run create:redirects && npm run move:downloads", + "prebuild": "npm run create:icons && npm run create:redirects && npm run move:downloads && npm run create:exif", "start": "npm run prebuild && astro dev --config .config/astro.config.mjs", "build": "npm run prebuild && astro build --config .config/astro.config.mjs", "preview": "astro preview", @@ -24,6 +24,7 @@ "new": "ts-node --esm scripts/new/index.ts", "create:icons": "ts-node --esm scripts/create-icons/index.ts", "create:redirects": "ts-node --esm scripts/redirect-from.ts", + "create:exif": "ts-node --esm scripts/create-exif/index.ts", "move:downloads": "ts-node --esm scripts/move-downloads.ts" }, "dependencies": { diff --git a/scripts/create-exif/format.ts b/scripts/create-exif/format.ts new file mode 100644 index 00000000..bcfd3cb0 --- /dev/null +++ b/scripts/create-exif/format.ts @@ -0,0 +1,89 @@ +// +// Extract EXIF & IPTC data from images +// write to json file `.config/exif.json` +// +import getCoordinates from 'dms2dec' +import Fraction from 'fraction.js' +import type { ExifFormatted, FastExif } from './types.ts' + +function formatGps(gpsData: FastExif['gps']): { + latitude: string + longitude: string +} { + if (!gpsData) return { latitude: '', longitude: '' } + + const { GPSLatitudeRef, GPSLatitude, GPSLongitudeRef, GPSLongitude } = gpsData + + const GPSdec = getCoordinates( + GPSLatitude as number[], + GPSLatitudeRef as string, + GPSLongitude as number[], + GPSLongitudeRef as string + ) + + const latitude = GPSdec[0] + const longitude = GPSdec[1] + + return { latitude, longitude } +} + +function formatExposure(exposureMode: number) { + if (exposureMode === null || exposureMode === undefined) return + + const exposureShortened = parseFloat(exposureMode.toFixed(2)) + let exposure + + if (exposureMode === 0) { + exposure = `+/- ${exposureShortened} ev` + } else if (exposureMode > 0) { + exposure = `+ ${exposureShortened} ev` + } else { + exposure = `${exposureShortened} ev` + } + + return exposure +} + +export function formatExif(exifData: FastExif): ExifFormatted | undefined { + if (!exifData?.exif) return + + const { Model } = exifData.image as { Model: string } + const { + ISO, + FNumber, + ExposureTime, + FocalLength, + FocalLengthIn35mmFormat, + ExposureBiasValue, + ExposureMode, + LensModel + } = exifData.exif + + const iso = `ISO ${ISO}` + const fstop = `ƒ/${FNumber}` + const focalLength = `${FocalLengthIn35mmFormat || FocalLength}mm` + + // Shutter speed + const { n, d } = new Fraction(ExposureTime as number) + const shutterspeed = `${n}/${d}s` + + // GPS + let latitude + let longitude + if (exifData.gps) ({ latitude, longitude } = formatGps(exifData.gps)) + + // Exposure + const exposureValue = (ExposureBiasValue || ExposureMode) as number + const exposure = formatExposure(exposureValue) + + return { + iso, + model: Model, + fstop, + shutterspeed, + focalLength, + lensModel: LensModel, + exposure, + gps: { latitude, longitude } + } +} diff --git a/scripts/create-exif/index.ts b/scripts/create-exif/index.ts index 238eb108..0b4e134b 100644 --- a/scripts/create-exif/index.ts +++ b/scripts/create-exif/index.ts @@ -1,148 +1,80 @@ -import type { Actions, Node, NodePluginArgs } from 'gatsby' -import getCoordinates from 'dms2dec' +import fs from 'node:fs/promises' +import path from 'node:path' import { read } from 'fast-exif' -import Fraction from 'fraction.js' -import fs from 'fs' import iptc from 'node-iptc' +import ora from 'ora' +import type { Exif, ExifFormatted } from './types.ts' +import { formatExif } from './format.ts' -export const createExif = async ( - node: Node, - actions: Actions, - createNodeId: NodePluginArgs['createNodeId'] -) => { - if (!node?.absolutePath) return +const imageFolder = path.join(process.cwd(), 'content', 'photos') +const outputFilePath = '.config/exif.json' + +const spinner = ora('Extracting EXIF metadata from all photos').start() + +async function readOutExif(filePath: string): Promise { + if (!filePath) return try { // exif - const exifData = (await read( - node.absolutePath as string, - true - )) as Queries.ImageExif + const exifData = await read(filePath, true) if (!exifData) return // iptc - const file = fs.readFileSync(node.absolutePath as string) + const file = await fs.readFile(filePath) const iptcData = iptc(file) - createNodes(exifData, iptcData, node, actions, createNodeId) - } catch (error: any) { - console.error(`${node.name}: ${error.message}`) - } -} + // format before output + const exifDataFormatted = formatExif(exifData) + const imageId = path.basename(filePath, path.extname(filePath)) -function createNodes( - exifData: Queries.ImageExif, - iptcData: any, - node: Node, - actions: Actions, - createNodeId: NodePluginArgs['createNodeId'] -) { - const { createNodeField, createNode, createParentChildLink } = actions - const exifDataFormatted = formatExif(exifData) - - const exif = { - ...exifData, - iptc: { ...iptcData }, - formatted: { ...exifDataFormatted } - } - - const exifNode: any = { - id: createNodeId(`${node.id} >> ImageExif`), - children: [], - ...exif, - parent: node.id, - internal: { - contentDigest: `${node.internal.contentDigest}`, - type: 'ImageExif' + const exif = { + image: imageId, + exif: { ...exifDataFormatted } as ExifFormatted, + iptc: { ...iptcData } } - } - // add exif fields to existing type file - createNodeField({ node, name: 'exif', value: exif }) - - // create new nodes from all exif data - // allowing to be queried with imageExif & AllImageExif - createNode(exifNode) - createParentChildLink({ parent: node, child: exifNode }) -} - -function formatExif(exifData: Queries.ImageExif) { - if (!exifData.exif) return - - const { Model } = exifData.image - const { - ISO, - FNumber, - ExposureTime, - FocalLength, - FocalLengthIn35mmFormat, - ExposureBiasValue, - ExposureMode, - LensModel - } = exifData.exif - - const iso = `ISO ${ISO}` - const fstop = `ƒ/${FNumber}` - const focalLength = `${FocalLengthIn35mmFormat || FocalLength}mm` - - // Shutter speed - const { n, d } = new Fraction(ExposureTime) - const shutterspeed = `${n}/${d}s` - - // GPS - let latitude - let longitude - if (exifData.gps) ({ latitude, longitude } = formatGps(exifData.gps)) - - // Exposure - const exposure = formatExposure(ExposureBiasValue || ExposureMode) - - return { - iso, - model: Model, - fstop, - shutterspeed, - focalLength, - lensModel: LensModel, - exposure, - gps: { latitude, longitude } + return exif + } catch (error: any) { + console.error(`${filePath}: ${error.message}`) } } -function formatGps(gpsData: Queries.ImageExif['gps']): { - latitude: string - longitude: string -} { - if (!gpsData) return { latitude: '', longitude: '' } +async function processImages(folderPath: string): Promise { + const allExif: Exif[] = [] - const { GPSLatitudeRef, GPSLatitude, GPSLongitudeRef, GPSLongitude } = gpsData + try { + const files = await fs.readdir(folderPath, { recursive: true }) - const GPSdec = getCoordinates( - GPSLatitude, - GPSLatitudeRef, - GPSLongitude, - GPSLongitudeRef - ) + for (const file of files) { + const filePath = path.join(folderPath, file) + const stats = await fs.stat(filePath) - const latitude = GPSdec[0] - const longitude = GPSdec[1] + if (stats.isFile()) { + // Check if it's an image file based on its file extension + const fileExtension = path.extname(filePath).toLowerCase() - return { latitude, longitude } -} - -function formatExposure(exposureMode: Queries.ImageExifExif['ExposureMode']) { - if (exposureMode === null || exposureMode === undefined) return - - const exposureShortened = parseFloat(exposureMode.toFixed(2)) - let exposure - - if (exposureMode === 0) { - exposure = `+/- ${exposureShortened} ev` - } else if (exposureMode > 0) { - exposure = `+ ${exposureShortened} ev` - } else { - exposure = `${exposureShortened} ev` + if (fileExtension === '.jpg' || fileExtension === '.jpeg') { + const exif = await readOutExif(filePath) + if (!exif) continue + allExif.push(exif) + } + } + } + } catch (err) { + console.error('Error:', (err as Error).message) } - return exposure + return allExif +} + +try { + const allExif = await processImages(imageFolder) + const allExifJSON = JSON.stringify(allExif, null, 2) + + // Write the redirects object to the output file + fs.writeFile(outputFilePath, allExifJSON, 'utf-8') + + spinner.succeed(`Extracted EXIF data from ${allExif.length} photos`) +} catch (error: any) { + spinner.fail((error as Error).message) } diff --git a/scripts/create-exif/types.ts b/scripts/create-exif/types.ts new file mode 100644 index 00000000..edd35015 --- /dev/null +++ b/scripts/create-exif/types.ts @@ -0,0 +1,24 @@ +export type FastExif = { + image?: Record | undefined + thumbnail?: Record | undefined + exif?: Record | undefined + gps?: Record | undefined + interoperability?: Record | undefined +} + +export type ExifFormatted = { + iso: string + model: any + fstop: string + shutterspeed: string + focalLength: string + lensModel: any + exposure: string | undefined + gps: { latitude: string | undefined; longitude: string | undefined } +} + +export type Exif = { + image: string + exif: ExifFormatted + iptc: any +} diff --git a/src/@types/dmsdec/index.d.ts b/src/@types/dmsdec/index.d.ts new file mode 100644 index 00000000..517a478b --- /dev/null +++ b/src/@types/dmsdec/index.d.ts @@ -0,0 +1,8 @@ +declare module 'dms2dec' { + export default function dms2dec( + lat: readonly number[], + latRef: string, + lon: readonly number[], + lonRef: string + ): [latDec: string, lonDec: string] +} diff --git a/src/@types/node-iptc/index.d.ts b/src/@types/node-iptc/index.d.ts new file mode 100644 index 00000000..1e532094 --- /dev/null +++ b/src/@types/node-iptc/index.d.ts @@ -0,0 +1,71 @@ +declare module 'node-iptc' { + export default function iptc(buffer: Buffer): IptcData +} + +type IptcData = { + object_type_reference?: string + object_attribute_reference?: string + object_name?: string + edit_status?: string + editorial_update?: string + urgency?: string + subject_reference?: string + category?: string + supplemental_categories?: string[] + fixture_id?: string[] + keywords?: string[] + content_location_code?: string[] + content_location_name?: string[] + release_date?: string + release_time?: string + expiration_date?: string + expiration_time?: string + special_instructions?: string + action_advised?: string + reference_service?: string[] + reference_date?: string[] + reference_number?: string[] + date_created?: string + time_created?: string + digital_date_created?: string + digital_time_created?: string + originating_program?: string + program_version?: string + object_cycle?: string + by_line?: string[] + caption?: string // not in spec, but observed in situ + by_line_title?: string[] + city?: string + sub_location?: string + province_or_state?: string + country_or_primary_location_code?: string + country_or_primary_location_name?: string + original_transmission_reference?: string + headline?: string + credit?: string + source?: string + copyright_notice?: string + contact?: string + local_caption?: string + caption_writer?: string[] + rasterized_caption?: string + image_type?: string + image_orientation?: string + language_identifier?: string + audio_type?: string + audio_sampling_rate?: string + audio_sampling_resolution?: string + audio_duration?: string + audio_outcue?: string + + job_id?: string + master_document_id?: string + short_document_id?: string + unique_document_id?: string + owner_id?: string + + object_preview_file_format?: string + object_preview_file_format_version?: string + object_preview_data?: string + date_time?: Date +} diff --git a/src/@types/node_modules.d.ts b/src/@types/node_modules.d.ts index 6a834d57..5485b089 100644 --- a/src/@types/node_modules.d.ts +++ b/src/@types/node_modules.d.ts @@ -1,13 +1,2 @@ declare module 'pigeon-maps' declare module 'pigeon-marker' -declare module 'unified' -declare module 'node-iptc' - -declare module 'dms2dec' { - export default function dms2dec( - lat: readonly number[], - latRef: string, - lon: readonly number[], - lonRef: string - ): [latDec: string, lonDec: string] -} diff --git a/tsconfig.json b/tsconfig.json index cf34865e..e558878e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,7 +13,8 @@ "@images/*": ["src/images/*"], "@lib/*": ["src/lib/*"], "@content/*": ["content/*"] - } + }, + "typeRoots": ["./src/@types", "./node_modules/@types"] } // "exclude": ["node_modules", "public", "dist", "./**/*.js"], // "include": [