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;
+};