From 64a49c85a3a67b19f54529119d8be6b8bdb71ca8 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Wed, 19 Feb 2025 17:18:59 -0600 Subject: [PATCH] Add parsing for remaining fujifilm recipe fields --- __tests__/fujifilm.test.ts | 16 ++ src/components/Container.tsx | 2 +- src/photo/form/PhotoForm.tsx | 15 +- src/photo/server.ts | 12 +- src/platforms/fujifilm/index.ts | 40 ++++- src/platforms/fujifilm/recipe.ts | 217 ++++++++++++++++++++++++--- src/platforms/fujifilm/simulation.ts | 8 +- 7 files changed, 265 insertions(+), 45 deletions(-) create mode 100644 __tests__/fujifilm.test.ts diff --git a/__tests__/fujifilm.test.ts b/__tests__/fujifilm.test.ts new file mode 100644 index 00000000..ef3675e7 --- /dev/null +++ b/__tests__/fujifilm.test.ts @@ -0,0 +1,16 @@ +import { processTone } from '@/platforms/fujifilm/recipe'; + +describe('Fujifilm', () => { + describe('recipes', () => { + it('process tone', () => { + expect(processTone(0)).toBe(0); + expect(processTone(8)).toBe(-0.5); + expect(processTone(16)).toBe(-1); + expect(processTone(32)).toBe(-2); + expect(processTone(-16)).toBe(1); + expect(processTone(-32)).toBe(2); + expect(processTone(-48)).toBe(3); + expect(processTone(-64)).toBe(4); + }); + }); +}); diff --git a/src/components/Container.tsx b/src/components/Container.tsx index 50a05bb4..5d0c43fd 100644 --- a/src/components/Container.tsx +++ b/src/components/Container.tsx @@ -31,7 +31,7 @@ export default function Container({ case 'blue': return [ 'text-blue-900 dark:text-blue-300', 'bg-blue-50/50 dark:bg-blue-950/30', - 'border-blue-200 dark:border-blue-600/20', + 'border-blue-200 dark:border-blue-500/40', ]; case 'red': return [ 'text-red-600 dark:text-red-500/90', diff --git a/src/photo/form/PhotoForm.tsx b/src/photo/form/PhotoForm.tsx index 65915b11..d79875a7 100644 --- a/src/photo/form/PhotoForm.tsx +++ b/src/photo/form/PhotoForm.tsx @@ -101,12 +101,19 @@ export default function PhotoForm({ if (changedKeys.length > 0) { const fields = convertFormKeysToLabels(changedKeys); - toastSuccess( - `Updated EXIF fields: ${fields.join(', ')}`, - 8000, + // Delay toasts to avoid render sync issue + const timeout = setTimeout( + () => toastSuccess(`Updated EXIF fields: ${fields.join(', ')}`, 8000), + 100, ); + return () => clearTimeout(timeout); } else { - toastWarning('No new EXIF data found'); + // Delay toasts to avoid render sync issue + const timeout = setTimeout( + () => toastWarning('No new EXIF data found'), + 100, + ); + return () => clearTimeout(timeout); } } }, [updatedExifData]); diff --git a/src/photo/server.ts b/src/photo/server.ts index 8f77b66d..e9c94cf4 100644 --- a/src/photo/server.ts +++ b/src/photo/server.ts @@ -15,8 +15,10 @@ import { PRESERVE_ORIGINAL_UPLOADS, } from '@/app/config'; import { isExifForFujifilm } from '@/platforms/fujifilm'; -import { getFujifilmRecipeFromMakerNote } from '@/platforms/fujifilm/recipe'; - +import { + FujifilmRecipe, + getFujifilmRecipeFromMakerNote, +} from '@/platforms/fujifilm/recipe'; const IMAGE_WIDTH_RESIZE = 200; const IMAGE_WIDTH_BLUR = 200; @@ -49,6 +51,7 @@ export const extractImageDataFromBlobPath = async ( let exifData: ExifData | undefined; let filmSimulation: FilmSimulation | undefined; + let recipe: Partial | undefined; let blurData: string | undefined; let imageResizedBase64: string | undefined; let shouldStripGpsData = false; @@ -79,9 +82,8 @@ export const extractImageDataFromBlobPath = async ( const makerNote = exifDataBinary.tags?.MakerNote; if (Buffer.isBuffer(makerNote)) { filmSimulation = getFujifilmSimulationFromMakerNote(makerNote); - console.log({ - recipe: getFujifilmRecipeFromMakerNote(makerNote), - }); + recipe = getFujifilmRecipeFromMakerNote(makerNote); + console.log(recipe); } } diff --git a/src/platforms/fujifilm/index.ts b/src/platforms/fujifilm/index.ts index 485a5f1d..30a17087 100644 --- a/src/platforms/fujifilm/index.ts +++ b/src/platforms/fujifilm/index.ts @@ -1,5 +1,6 @@ // MakerNote tag IDs and values referenced from: -// github.com/exiftool/exiftool/blob/master/lib/Image/ExifTool/FujiFilm.pm +// - github.com/exiftool/exiftool/blob/master/lib/Image/ExifTool/FujiFilm.pm +// - exiftool.org/TagNames/FujiFilm.html import type { ExifData } from 'ts-exif-parser'; @@ -9,6 +10,7 @@ const BYTE_INDEX_TAG_COUNT = 12; const BYTE_INDEX_FIRST_TAG = 14; const BYTES_PER_TAG = 12; const BYTE_OFFSET_TAG_TYPE = 2; +const BYTE_OFFSET_TAG_SIZE = 4; const BYTE_OFFSET_TAG_VALUE = 8; export const isExifForFujifilm = (data: ExifData) => @@ -16,7 +18,7 @@ export const isExifForFujifilm = (data: ExifData) => export const parseFujifilmMakerNote = ( bytes: Buffer, - valueForTagUInt: (tagId: number, value: number) => void, + valueForTagNumbers: (tagId: number, numbers: number[]) => void, ) => { const tagCount = bytes.readUint16LE(BYTE_INDEX_TAG_COUNT); for (let i = 0; i < tagCount; i++) { @@ -24,22 +26,46 @@ export const parseFujifilmMakerNote = ( if (index + BYTES_PER_TAG < bytes.length) { const tagId = bytes.readUInt16LE(index); const tagType = bytes.readUInt16LE(index + BYTE_OFFSET_TAG_TYPE); + const tagValueSize = bytes.readUInt16LE(index + BYTE_OFFSET_TAG_SIZE); switch (tagType) { + // Int8 (UInt8 read as Int8) + case 1: + valueForTagNumbers( + tagId, + [bytes.readInt8(index + BYTE_OFFSET_TAG_VALUE)], + ); + break; // UInt16 case 3: - valueForTagUInt( + valueForTagNumbers( tagId, - bytes.readUInt16LE(index + BYTE_OFFSET_TAG_VALUE), + [bytes.readUInt16LE(index + BYTE_OFFSET_TAG_VALUE)], ); break; // UInt32 case 4: - valueForTagUInt( + valueForTagNumbers( tagId, - bytes.readUInt32LE(index + BYTE_OFFSET_TAG_VALUE), + [bytes.readUInt32LE(index + BYTE_OFFSET_TAG_VALUE)], ); break; + // Int32 + case 9: + if (tagValueSize === 1) { + valueForTagNumbers( + tagId, + [bytes.readInt32LE(index + BYTE_OFFSET_TAG_VALUE)], + ); + } else { + const offset = bytes.readInt32LE(index + BYTE_OFFSET_TAG_VALUE); + const values: number[] = []; + for (let i = 0; i < tagValueSize; i++) { + values.push(bytes.readInt32LE(offset + i * 4)); + } + valueForTagNumbers(tagId, values); + } + break; } } } -}; \ No newline at end of file +}; diff --git a/src/platforms/fujifilm/recipe.ts b/src/platforms/fujifilm/recipe.ts index 099232c9..8a75258d 100644 --- a/src/platforms/fujifilm/recipe.ts +++ b/src/platforms/fujifilm/recipe.ts @@ -1,46 +1,158 @@ import { parseFujifilmMakerNote } from '.'; -// const TAG_ID_DYNAMIC_RANGE = 0x1400; -// const TAG_ID_DYNAMIC_RANGE_SETTING = 0x1402; const TAG_ID_DEVELOPMENT_DYNAMIC_RANGE = 0x1403; -// const TAG_ID_AUTO_DYNAMIC_RANGE = 0x140b; -// const TAG_ID_HIGHLIGHT = 0x1041; -// const TAG_ID_SHADOW = 0x1040; -// const TAG_ID_COLOR = 0x1003; -// const TAG_ID_NOISE_REDUCTION = 0x100b; -// const TAG_ID_SHARPNESS = 0x1001; -// const TAG_ID_CLARITY = 0x100f; -// const TAG_ID_GRAIN_EFFECT_ROUGHNESS = 0x1047; -// const TAG_ID_GRAIN_EFFECT_SIZE = 0x104c; -// const TAG_ID_COLOR_CHROME_EFFECT = 0x1048; -// const TAG_ID_COLOR_CHROME_FX_BLUE = 0x104e; -// const TAG_ID_WHITE_BALANCE = 0x1002; -// const TAG_ID_WHITE_BALANCE_FINE_TUNE = 0x1003; -// TBD -// const TAG_ID_TONE = 0x1004; -// const TAG_ID_CONTRAST = 0x1006; +const TAG_ID_HIGHLIGHT = 0x1041; +const TAG_ID_SHADOW = 0x1040; +const TAG_ID_SATURATION = 0x1003; +const TAG_ID_NOISE_REDUCTION = 0x100e; +const TAG_ID_NOISE_REDUCTION_LEGACY = 0x100b; +const TAG_ID_SHARPNESS = 0x1001; +const TAG_ID_CLARITY = 0x100f; +const TAG_ID_GRAIN_EFFECT_ROUGHNESS = 0x1047; +const TAG_ID_GRAIN_EFFECT_SIZE = 0x104c; +const TAG_ID_COLOR_CHROME_EFFECT = 0x1048; +const TAG_ID_COLOR_CHROME_FX_BLUE = 0x104e; +const TAG_ID_WHITE_BALANCE = 0x1002; +const TAG_ID_WHITE_BALANCE_FINE_TUNE = 0x100a; +const TAG_ID_BW_ADJUSTMENT = 0x1049; +const TAG_ID_BW_MAGENTA_GREEN = 0x104b; + +type WeakStrong = 'off' | 'weak' | 'strong'; export interface FujifilmRecipe { dynamicRange: number highlight: number shadow: number color: number - noiseReduction: number + highISONoiseReduction: number + noiseReductionLegacy: string sharpness: number clarity: number grainEffect: { - roughness: 'strong' | 'medium' | 'weak' - size: 'small' | 'large' + roughness: WeakStrong + size: 'off' | 'small' | 'large' } - colorChromeEffect: 'strong' | 'medium' | 'weak' - colorChromeFXBlue: 'off' | 'weak' | 'strong' + colorChromeEffect: WeakStrong + colorChromeFXBlue: WeakStrong whiteBalance: { type: string red: number blue: number } + bwAdjustment: number + bwMagentaGreen: number } +const DEFAULT_GRAIN_EFFECT = { + roughness: 'off', + size: 'off', +} as const; + +const DEFAULT_WHITE_BALANCE = { + type: 'auto', + red: 0, + blue: 0, +} as const; + +export const processTone = (value: number) => + value === 0 ? 0 : -(value / 16); + +export const processSaturation = (value: number) => { + switch (value) { + case 0x4e0: return -4; + case 0x4c0: return -3; + case 0x400: return -2; + case 0x180: return -1; + case 0x80: return 1; + case 0x100: return 2; + case 0xc0: return 3; + case 0xe0: return 4; + default: return 0; + } +}; + +export const processNoiseReductionLegacy = (value: number) => { + switch (value) { + case 0x40: return 'low'; + case 0x80: return 'normal'; + default: return 'n/a'; + } +}; + +export const processNoiseReduction = (value: number) => { + switch (value) { + case 0x2e0: return -4; + case 0x2c0: return -3; + case 0x200: return -2; + case 0x280: return -1; + case 0x180: return 1; + case 0x100: return 2; + case 0x1c0: return 3; + case 0x1e0: return 4; + default: return 0; + } +}; + +export const processSharpness = (value: number) => { + switch (value) { + case 0x0: return -4; + case 0x1: return -3; + case 0x2: return -2; + case 0x82: return -1; + case 0x84: return 1; + case 0x4: return 2; + case 0x5: return 3; + case 0x6: return 4; + default: return 0; + } +}; + +export const processClarity = (value: number) => value / 1000; + +export const processWeakStrong = (value: number): WeakStrong => { + switch (value) { + case 32: return 'weak'; + case 64: return 'strong'; + default: return 'off'; + } +}; + +export const processGrainEffectSize = ( + value: number, +): FujifilmRecipe['grainEffect']['size'] => { + switch (value) { + case 16: return 'small'; + case 32: return 'large'; + default: return 'off'; + } +}; + +export const processWhiteBalanceType = (value: number) => { + switch (value) { + case 0x1: return 'auto-white-priority'; + case 0x2: return 'auto-ambiance-priority'; + case 0x100: return 'daylight'; + case 0x200: return 'cloudy'; + case 0x300: return 'daylight-fluorescent'; + case 0x301: return 'day-white-fluorescent'; + case 0x302: return 'white-fluorescent'; + case 0x303: return 'warm-white-fluorescent'; + case 0x304: return 'living-room-warm-white-fluorescent'; + case 0x400: return 'incandescent'; + case 0x500: return 'flash'; + case 0x600: return 'underwater'; + case 0xf00: return 'custom'; + case 0xf01: return 'custom-2'; + case 0xf02: return 'custom-3'; + case 0xf03: return 'custom-4'; + case 0xf04: return 'custom-5'; + case 0xff0: return 'kelvin'; + default: return 'auto'; + } +}; + +export const processWhiteBalanceComponent = (value: number) => value / 20; + export const getFujifilmRecipeFromMakerNote = ( bytes: Buffer, ): Partial => { @@ -48,10 +160,65 @@ export const getFujifilmRecipeFromMakerNote = ( parseFujifilmMakerNote( bytes, - (tag, value) => { + (tag, numbers) => { switch (tag) { case TAG_ID_DEVELOPMENT_DYNAMIC_RANGE: - recipe.dynamicRange = value; + recipe.dynamicRange = numbers[0]; + break; + case TAG_ID_HIGHLIGHT: + recipe.highlight = processTone(numbers[0]); + break; + case TAG_ID_SHADOW: + recipe.shadow = processTone(numbers[0]); + break; + case TAG_ID_SATURATION: + recipe.color = processSaturation(numbers[0]); + break; + case TAG_ID_NOISE_REDUCTION: + recipe.highISONoiseReduction = processNoiseReduction(numbers[0]); + break; + case TAG_ID_NOISE_REDUCTION_LEGACY: + recipe.noiseReductionLegacy = + processNoiseReductionLegacy(numbers[0]); + break; + case TAG_ID_SHARPNESS: + recipe.sharpness = processSharpness(numbers[0]); + break; + case TAG_ID_CLARITY: + recipe.clarity = processClarity(numbers[0]); + break; + case TAG_ID_GRAIN_EFFECT_ROUGHNESS: + if (!recipe.grainEffect) { recipe.grainEffect = DEFAULT_GRAIN_EFFECT; } + recipe.grainEffect.roughness = processWeakStrong(numbers[0]); + break; + case TAG_ID_GRAIN_EFFECT_SIZE: + if (!recipe.grainEffect) { recipe.grainEffect = DEFAULT_GRAIN_EFFECT; } + recipe.grainEffect.size = processGrainEffectSize(numbers[0]); + break; + case TAG_ID_COLOR_CHROME_EFFECT: + recipe.colorChromeEffect = processWeakStrong(numbers[0]); + break; + case TAG_ID_COLOR_CHROME_FX_BLUE: + recipe.colorChromeFXBlue = processWeakStrong(numbers[0]); + break; + case TAG_ID_WHITE_BALANCE: + if (!recipe.whiteBalance) { + recipe.whiteBalance = DEFAULT_WHITE_BALANCE; + } + recipe.whiteBalance.type = processWhiteBalanceType(numbers[0]); + break; + case TAG_ID_WHITE_BALANCE_FINE_TUNE: + if (!recipe.whiteBalance) { + recipe.whiteBalance = DEFAULT_WHITE_BALANCE; + } + recipe.whiteBalance.red = processWhiteBalanceComponent(numbers[0]); + recipe.whiteBalance.blue = processWhiteBalanceComponent(numbers[1]); + break; + case TAG_ID_BW_ADJUSTMENT: + recipe.bwAdjustment = numbers[0]; + break; + case TAG_ID_BW_MAGENTA_GREEN: + recipe.bwMagentaGreen = numbers[0]; break; } }, diff --git a/src/platforms/fujifilm/simulation.ts b/src/platforms/fujifilm/simulation.ts index 63391284..9c92457d 100644 --- a/src/platforms/fujifilm/simulation.ts +++ b/src/platforms/fujifilm/simulation.ts @@ -225,13 +225,15 @@ export const getFujifilmSimulationFromMakerNote = ( parseFujifilmMakerNote( bytes, - (tag, value) => { + (tag, numbers) => { switch (tag) { case TAG_ID_SATURATION: - filmModeFromSaturation = getFujifilmSimulationFromSaturation(value); + filmModeFromSaturation = + getFujifilmSimulationFromSaturation(numbers[0]); break; case TAG_ID_FILM_MODE: - filmMode = getFujifilmMode(value); + filmMode = + getFujifilmMode(numbers[0]); break; } },