Parse and store fujifilm simulations

This commit is contained in:
Sam Becker 2023-10-25 12:38:19 -05:00
parent cbf4b89067
commit b9cba9b14b
5 changed files with 169 additions and 11 deletions

View File

@ -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",

View File

@ -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
? <PhotoForm
initialPhotoForm={{
extension,
url: decodeURIComponent(uploadPath),
...convertExifToFormData(data),
...convertExifToFormData(exifDataForm, filmSimulation),
}}
/>
: null}

View File

@ -98,10 +98,14 @@ export default function PhotoForm({
required,
readOnly,
hideIfEmpty,
hideBasedOnCamera,
loadingMessage,
checkbox,
}]) =>
(!hideIfEmpty || formData[key]) &&
(
(!hideIfEmpty || formData[key]) &&
!hideBasedOnCamera?.(formData.make)
) &&
<FieldSetWithStatus
key={key}
id={key}

View File

@ -8,6 +8,7 @@ import { getOffsetFromExif } from '@/utility/exif';
import { toFixedNumber } from '@/utility/number';
import { convertStringToArray } from '@/utility/string';
import { generateNanoid } from '@/utility/nanoid';
import { FujifilmSimulation } from '@/utility/fujifilm';
export type PhotoFormData = Record<keyof PhotoDbInsert, string>;
@ -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<keyof PhotoFormData, FormMeta> = {
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<keyof PhotoFormData, FormMeta> = {
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<keyof PhotoExif, string | undefined> => ({
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,

120
src/utility/fujifilm.ts Normal file
View File

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