Parse and store fujifilm simulations
This commit is contained in:
parent
cbf4b89067
commit
b9cba9b14b
7
.vscode/settings.json
vendored
7
.vscode/settings.json
vendored
@ -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",
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
120
src/utility/fujifilm.ts
Normal 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;
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user