From b9cba9b14b45266d45e4dd584afc5c784b56d1b1 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Wed, 25 Oct 2023 12:38:19 -0500 Subject: [PATCH] Parse and store fujifilm simulations --- .vscode/settings.json | 7 + .../admin/uploads/[uploadPath]/page.tsx | 34 ++++- src/photo/PhotoForm.tsx | 6 +- src/photo/form.ts | 13 +- src/utility/fujifilm.ts | 120 ++++++++++++++++++ 5 files changed, 169 insertions(+), 11 deletions(-) create mode 100644 src/utility/fujifilm.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 6eb8f0ac..91af0e49 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,24 +1,31 @@ { "cSpell.words": [ "ABCDEFGHIJKLMNOP", + "Acros", "ARROWLEFT", "ARROWRIGHT", + "Astia", "camelcase", + "Eterna", "exif", + "exiftool", "ghijklmnopqrstuv", "hgetall", "hset", "Lightbox", "nanoids", "nextjs", + "Provia", "qaub", "QRSTUVWXYZ", + "Reala", "skippable", "sonner", "thephotoblog", "trpc", "unnest", "UsKSGcbt", + "Velvia", "WRHGZC", "wxyz", "zadd", diff --git a/src/app/(auth-state)/admin/uploads/[uploadPath]/page.tsx b/src/app/(auth-state)/admin/uploads/[uploadPath]/page.tsx index a4ea30e5..2dc78856 100644 --- a/src/app/(auth-state)/admin/uploads/[uploadPath]/page.tsx +++ b/src/app/(auth-state)/admin/uploads/[uploadPath]/page.tsx @@ -1,9 +1,14 @@ import PhotoForm from '@/photo/PhotoForm'; -import { ExifParserFactory } from 'ts-exif-parser'; +import { ExifData, ExifParserFactory } from 'ts-exif-parser'; import { convertExifToFormData } from '@/photo/form'; import AdminChildPage from '@/components/AdminChildPage'; import { getExtensionFromBlobUrl, getIdFromBlobUrl } from '@/services/blob'; import { PATH_ADMIN_UPLOADS } from '@/site/paths'; +import { + FujifilmSimulation, + getFujifilmSimulationFromMakerNote, + isExifForFujifilm, +} from '@/utility/fujifilm'; interface Params { params: { uploadPath: string } @@ -19,12 +24,27 @@ export default async function UploadPage({ params: { uploadPath } }: Params) { .then(res => res.arrayBuffer()) : undefined; - let data; + let exifDataForm: ExifData | undefined; + let filmSimulation: FujifilmSimulation | undefined; if (fileBytes) { - data = ExifParserFactory - .create(Buffer.from(fileBytes)) - .parse(); + const parser = ExifParserFactory.create(Buffer.from(fileBytes)); + + // Data for form + parser.enableBinaryFields(false); + exifDataForm = parser.parse(); + + // Capture film simulation for Fujifilm cameras + if (isExifForFujifilm(exifDataForm)) { + // Parse exif data again with binary fields + // in order to access MakerNote tag + parser.enableBinaryFields(true); + const exifDataBinary = parser.parse(); + const makerNote = exifDataBinary.tags?.MakerNote; + if (Buffer.isBuffer(makerNote)) { + filmSimulation = getFujifilmSimulationFromMakerNote(makerNote); + } + } } return ( @@ -33,12 +53,12 @@ export default async function UploadPage({ params: { uploadPath } }: Params) { backLabel="Uploads" breadcrumb={getIdFromBlobUrl(url)} > - {data + {exifDataForm ? : null} diff --git a/src/photo/PhotoForm.tsx b/src/photo/PhotoForm.tsx index b62d1c41..6f07a4ae 100644 --- a/src/photo/PhotoForm.tsx +++ b/src/photo/PhotoForm.tsx @@ -98,10 +98,14 @@ export default function PhotoForm({ required, readOnly, hideIfEmpty, + hideBasedOnCamera, loadingMessage, checkbox, }]) => - (!hideIfEmpty || formData[key]) && + ( + (!hideIfEmpty || formData[key]) && + !hideBasedOnCamera?.(formData.make) + ) && ; @@ -18,6 +19,7 @@ type FormMeta = { readOnly?: boolean hideIfEmpty?: boolean hideTemporarily?: boolean + hideBasedOnCamera?: (make?: string, mode?: string) => boolean loadingMessage?: string checkbox?: boolean }; @@ -33,6 +35,11 @@ const FORM_METADATA: Record = { aspectRatio: { label: 'aspect ratio', readOnly: true }, make: { label: 'camera make' }, model: { label: 'camera model' }, + filmSimulation: { + label: 'fujifilm simulation', + readOnly: true, + hideBasedOnCamera: make => make !== 'FUJIFILM', + }, focalLength: { label: 'focal length' }, focalLengthIn35MmFormat: { label: 'focal length 35mm-equivalent' }, fNumber: { label: 'aperture' }, @@ -42,7 +49,6 @@ const FORM_METADATA: Record = { locationName: { label: 'location name', hideTemporarily: true }, latitude: { label: 'latitude' }, longitude: { label: 'longitude' }, - filmSimulation: { label: 'film simulation', hideTemporarily: true }, priorityOrder: { label: 'priority order' }, takenAt: { label: 'taken at' }, takenAtNaive: { label: 'taken at (naive)' }, @@ -77,7 +83,8 @@ export const convertPhotoToFormData = ( }; export const convertExifToFormData = ( - data: ExifData + data: ExifData, + fujifilmSimulation?: FujifilmSimulation, ): Record => ({ aspectRatio: ( (data.imageSize?.width ?? 3.0) / @@ -93,7 +100,7 @@ export const convertExifToFormData = ( exposureCompensation: data.tags?.ExposureCompensation?.toString(), latitude: data.tags?.GPSLatitude?.toString(), longitude: data.tags?.GPSLongitude?.toString(), - filmSimulation: undefined, + filmSimulation: fujifilmSimulation, takenAt: data.tags?.DateTimeOriginal ? convertTimestampWithOffsetToPostgresString( data.tags?.DateTimeOriginal, diff --git a/src/utility/fujifilm.ts b/src/utility/fujifilm.ts new file mode 100644 index 00000000..b02bdf0e --- /dev/null +++ b/src/utility/fujifilm.ts @@ -0,0 +1,120 @@ +// MakerNote tag IDs and values referenced from: +// exiftool/lib/Image/ExifTool/Fujifilm.pm + +import type { ExifData } from 'ts-exif-parser'; + +const BYTE_INDEX_FIRST_TAG = 14; +const BYTES_PER_TAG = 12; +const BYTE_OFFSET_FOR_INT_VALUE = 8; + +const TAG_ID_SATURATION = 0x1003; +const TAG_ID_FILM_MODE = 0x1401; + +type FujifilmSimulationFromSaturation = + 'Monochrome' | + 'Monochrome + Ye Filter' | + 'Monochrome + R Filter' | + 'Monochrome + G Filter' | + 'Sepia' | + 'Acros' | + 'Acros + Ye Filter' | + 'Acros + R Filter' | + 'Acros + G Filter'; + +type FujifilmMode = + 'Provia / Standard' | + 'Astia / Soft' | + 'Velvia / Vivid' | + 'Studio Portrait' | + 'Pro Neg. Std' | + 'Pro Neg. Hi' | + 'Classic Chrome' | + 'Eterna / Cinema' | + 'Classic Neg.' | + 'Eterna Bleach Bypass' | + 'Nostalgic Neg.' | + 'Reala Ace'; + +export type FujifilmSimulation = + FujifilmSimulationFromSaturation | + FujifilmMode; + +export const isExifForFujifilm = (data: ExifData) => + data.tags?.Make === 'FUJIFILM'; + +const parseFujifilmMakerNote = ( + bytes: Buffer, + valueForTag: (tag: number, value: number) => void +) => { + for ( + let i = BYTE_INDEX_FIRST_TAG; + i + BYTES_PER_TAG < bytes.length; + i += BYTES_PER_TAG + ) { + const tag = bytes.readUInt16LE(i); + const value = bytes.readUInt16LE(i + BYTE_OFFSET_FOR_INT_VALUE); + valueForTag(tag, value); + } +}; + +const getFujifilmSimulationFromSaturation = ( + value?: number, +): FujifilmSimulationFromSaturation | undefined => { + switch (value) { + case 0x300: return 'Monochrome'; + case 0x301: return 'Monochrome + R Filter'; + case 0x302: return 'Monochrome + Ye Filter'; + case 0x303: return 'Monochrome + G Filter'; + case 0x310: return 'Sepia'; + case 0x500: return 'Acros'; + case 0x501: return 'Acros + R Filter'; + case 0x502: return 'Acros + Ye Filter'; + case 0x503: return 'Acros + G Filter'; + } +}; + +const getFujifilmMode = ( + value?: number, +): FujifilmMode | undefined => { + switch (value) { + case 0x000: return 'Provia / Standard'; + case 0x100: + case 0x110: + case 0x120: + case 0x130: return 'Astia / Soft'; + case 0x200: + case 0x400: return 'Velvia / Vivid'; + case 0x300: return 'Studio Portrait'; + case 0x500: return 'Pro Neg. Std'; + case 0x501: return 'Pro Neg. Hi'; + case 0x600: return 'Classic Chrome'; + case 0x700: return 'Eterna / Cinema'; + case 0x800: return 'Classic Neg.'; + case 0x900: return 'Eterna Bleach Bypass'; + case 0xa00: return 'Nostalgic Neg.'; + case 0xb00: return 'Reala Ace'; + } +}; + +export const getFujifilmSimulationFromMakerNote = ( + bytes: Buffer, +): FujifilmSimulation | undefined => { + let filmModeFromSaturation: FujifilmSimulationFromSaturation | undefined; + let filmMode: FujifilmMode | undefined; + + parseFujifilmMakerNote( + bytes, + (tag, value) => { + switch (tag) { + case TAG_ID_SATURATION: + filmModeFromSaturation = getFujifilmSimulationFromSaturation(value); + break; + case TAG_ID_FILM_MODE: + filmMode = getFujifilmMode(value); + break; + } + }, + ); + + return filmModeFromSaturation ?? filmMode; +};