mirror of
https://github.com/kremalicious/blog.git
synced 2024-06-30 21:52:05 +02:00
exif solution as prebuild step
This commit is contained in:
parent
a219076cd1
commit
edea2b320a
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -16,3 +16,4 @@ src/images/icons/
|
||||||
# autogenerated stuff
|
# autogenerated stuff
|
||||||
public/get/
|
public/get/
|
||||||
.config/redirects.json
|
.config/redirects.json
|
||||||
|
.config/exif.json
|
|
@ -7,7 +7,7 @@
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"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",
|
"start": "npm run prebuild && astro dev --config .config/astro.config.mjs",
|
||||||
"build": "npm run prebuild && astro build --config .config/astro.config.mjs",
|
"build": "npm run prebuild && astro build --config .config/astro.config.mjs",
|
||||||
"preview": "astro preview",
|
"preview": "astro preview",
|
||||||
|
@ -24,6 +24,7 @@
|
||||||
"new": "ts-node --esm scripts/new/index.ts",
|
"new": "ts-node --esm scripts/new/index.ts",
|
||||||
"create:icons": "ts-node --esm scripts/create-icons/index.ts",
|
"create:icons": "ts-node --esm scripts/create-icons/index.ts",
|
||||||
"create:redirects": "ts-node --esm scripts/redirect-from.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"
|
"move:downloads": "ts-node --esm scripts/move-downloads.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
89
scripts/create-exif/format.ts
Normal file
89
scripts/create-exif/format.ts
Normal file
|
@ -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 }
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,148 +1,80 @@
|
||||||
import type { Actions, Node, NodePluginArgs } from 'gatsby'
|
import fs from 'node:fs/promises'
|
||||||
import getCoordinates from 'dms2dec'
|
import path from 'node:path'
|
||||||
import { read } from 'fast-exif'
|
import { read } from 'fast-exif'
|
||||||
import Fraction from 'fraction.js'
|
|
||||||
import fs from 'fs'
|
|
||||||
import iptc from 'node-iptc'
|
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 (
|
const imageFolder = path.join(process.cwd(), 'content', 'photos')
|
||||||
node: Node,
|
const outputFilePath = '.config/exif.json'
|
||||||
actions: Actions,
|
|
||||||
createNodeId: NodePluginArgs['createNodeId']
|
const spinner = ora('Extracting EXIF metadata from all photos').start()
|
||||||
) => {
|
|
||||||
if (!node?.absolutePath) return
|
async function readOutExif(filePath: string): Promise<Exif | undefined> {
|
||||||
|
if (!filePath) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// exif
|
// exif
|
||||||
const exifData = (await read(
|
const exifData = await read(filePath, true)
|
||||||
node.absolutePath as string,
|
|
||||||
true
|
|
||||||
)) as Queries.ImageExif
|
|
||||||
if (!exifData) return
|
if (!exifData) return
|
||||||
|
|
||||||
// iptc
|
// iptc
|
||||||
const file = fs.readFileSync(node.absolutePath as string)
|
const file = await fs.readFile(filePath)
|
||||||
const iptcData = iptc(file)
|
const iptcData = iptc(file)
|
||||||
|
|
||||||
createNodes(exifData, iptcData, node, actions, createNodeId)
|
// format before output
|
||||||
} catch (error: any) {
|
const exifDataFormatted = formatExif(exifData)
|
||||||
console.error(`${node.name}: ${error.message}`)
|
const imageId = path.basename(filePath, path.extname(filePath))
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createNodes(
|
const exif = {
|
||||||
exifData: Queries.ImageExif,
|
image: imageId,
|
||||||
iptcData: any,
|
exif: { ...exifDataFormatted } as ExifFormatted,
|
||||||
node: Node,
|
iptc: { ...iptcData }
|
||||||
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'
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// add exif fields to existing type file
|
return exif
|
||||||
createNodeField({ node, name: 'exif', value: exif })
|
} catch (error: any) {
|
||||||
|
console.error(`${filePath}: ${error.message}`)
|
||||||
// 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 }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatGps(gpsData: Queries.ImageExif['gps']): {
|
async function processImages(folderPath: string): Promise<Exif[]> {
|
||||||
latitude: string
|
const allExif: Exif[] = []
|
||||||
longitude: string
|
|
||||||
} {
|
|
||||||
if (!gpsData) return { latitude: '', longitude: '' }
|
|
||||||
|
|
||||||
const { GPSLatitudeRef, GPSLatitude, GPSLongitudeRef, GPSLongitude } = gpsData
|
try {
|
||||||
|
const files = await fs.readdir(folderPath, { recursive: true })
|
||||||
|
|
||||||
const GPSdec = getCoordinates(
|
for (const file of files) {
|
||||||
GPSLatitude,
|
const filePath = path.join(folderPath, file)
|
||||||
GPSLatitudeRef,
|
const stats = await fs.stat(filePath)
|
||||||
GPSLongitude,
|
|
||||||
GPSLongitudeRef
|
|
||||||
)
|
|
||||||
|
|
||||||
const latitude = GPSdec[0]
|
if (stats.isFile()) {
|
||||||
const longitude = GPSdec[1]
|
// Check if it's an image file based on its file extension
|
||||||
|
const fileExtension = path.extname(filePath).toLowerCase()
|
||||||
|
|
||||||
return { latitude, longitude }
|
if (fileExtension === '.jpg' || fileExtension === '.jpeg') {
|
||||||
}
|
const exif = await readOutExif(filePath)
|
||||||
|
if (!exif) continue
|
||||||
function formatExposure(exposureMode: Queries.ImageExifExif['ExposureMode']) {
|
allExif.push(exif)
|
||||||
if (exposureMode === null || exposureMode === undefined) return
|
}
|
||||||
|
}
|
||||||
const exposureShortened = parseFloat(exposureMode.toFixed(2))
|
}
|
||||||
let exposure
|
} catch (err) {
|
||||||
|
console.error('Error:', (err as Error).message)
|
||||||
if (exposureMode === 0) {
|
|
||||||
exposure = `+/- ${exposureShortened} ev`
|
|
||||||
} else if (exposureMode > 0) {
|
|
||||||
exposure = `+ ${exposureShortened} ev`
|
|
||||||
} else {
|
|
||||||
exposure = `${exposureShortened} ev`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
24
scripts/create-exif/types.ts
Normal file
24
scripts/create-exif/types.ts
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
export type FastExif = {
|
||||||
|
image?: Record<string, unknown> | undefined
|
||||||
|
thumbnail?: Record<string, unknown> | undefined
|
||||||
|
exif?: Record<string, unknown> | undefined
|
||||||
|
gps?: Record<string, unknown> | undefined
|
||||||
|
interoperability?: Record<string, unknown> | 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
|
||||||
|
}
|
8
src/@types/dmsdec/index.d.ts
vendored
Normal file
8
src/@types/dmsdec/index.d.ts
vendored
Normal file
|
@ -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]
|
||||||
|
}
|
71
src/@types/node-iptc/index.d.ts
vendored
Normal file
71
src/@types/node-iptc/index.d.ts
vendored
Normal file
|
@ -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
|
||||||
|
}
|
11
src/@types/node_modules.d.ts
vendored
11
src/@types/node_modules.d.ts
vendored
|
@ -1,13 +1,2 @@
|
||||||
declare module 'pigeon-maps'
|
declare module 'pigeon-maps'
|
||||||
declare module 'pigeon-marker'
|
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]
|
|
||||||
}
|
|
||||||
|
|
|
@ -13,7 +13,8 @@
|
||||||
"@images/*": ["src/images/*"],
|
"@images/*": ["src/images/*"],
|
||||||
"@lib/*": ["src/lib/*"],
|
"@lib/*": ["src/lib/*"],
|
||||||
"@content/*": ["content/*"]
|
"@content/*": ["content/*"]
|
||||||
}
|
},
|
||||||
|
"typeRoots": ["./src/@types", "./node_modules/@types"]
|
||||||
}
|
}
|
||||||
// "exclude": ["node_modules", "public", "dist", "./**/*.js"],
|
// "exclude": ["node_modules", "public", "dist", "./**/*.js"],
|
||||||
// "include": [
|
// "include": [
|
||||||
|
|
Loading…
Reference in New Issue
Block a user