Add parsing for remaining fujifilm recipe fields

This commit is contained in:
Sam Becker 2025-02-19 17:18:59 -06:00
parent c9ffb96082
commit 64a49c85a3
7 changed files with 265 additions and 45 deletions

View File

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

View File

@ -31,7 +31,7 @@ export default function Container({
case 'blue': return [ case 'blue': return [
'text-blue-900 dark:text-blue-300', 'text-blue-900 dark:text-blue-300',
'bg-blue-50/50 dark:bg-blue-950/30', '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 [ case 'red': return [
'text-red-600 dark:text-red-500/90', 'text-red-600 dark:text-red-500/90',

View File

@ -101,12 +101,19 @@ export default function PhotoForm({
if (changedKeys.length > 0) { if (changedKeys.length > 0) {
const fields = convertFormKeysToLabels(changedKeys); const fields = convertFormKeysToLabels(changedKeys);
toastSuccess( // Delay toasts to avoid render sync issue
`Updated EXIF fields: ${fields.join(', ')}`, const timeout = setTimeout(
8000, () => toastSuccess(`Updated EXIF fields: ${fields.join(', ')}`, 8000),
100,
); );
return () => clearTimeout(timeout);
} else { } 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]); }, [updatedExifData]);

View File

@ -15,8 +15,10 @@ import {
PRESERVE_ORIGINAL_UPLOADS, PRESERVE_ORIGINAL_UPLOADS,
} from '@/app/config'; } from '@/app/config';
import { isExifForFujifilm } from '@/platforms/fujifilm'; 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_RESIZE = 200;
const IMAGE_WIDTH_BLUR = 200; const IMAGE_WIDTH_BLUR = 200;
@ -49,6 +51,7 @@ export const extractImageDataFromBlobPath = async (
let exifData: ExifData | undefined; let exifData: ExifData | undefined;
let filmSimulation: FilmSimulation | undefined; let filmSimulation: FilmSimulation | undefined;
let recipe: Partial<FujifilmRecipe> | undefined;
let blurData: string | undefined; let blurData: string | undefined;
let imageResizedBase64: string | undefined; let imageResizedBase64: string | undefined;
let shouldStripGpsData = false; let shouldStripGpsData = false;
@ -79,9 +82,8 @@ export const extractImageDataFromBlobPath = async (
const makerNote = exifDataBinary.tags?.MakerNote; const makerNote = exifDataBinary.tags?.MakerNote;
if (Buffer.isBuffer(makerNote)) { if (Buffer.isBuffer(makerNote)) {
filmSimulation = getFujifilmSimulationFromMakerNote(makerNote); filmSimulation = getFujifilmSimulationFromMakerNote(makerNote);
console.log({ recipe = getFujifilmRecipeFromMakerNote(makerNote);
recipe: getFujifilmRecipeFromMakerNote(makerNote), console.log(recipe);
});
} }
} }

View File

@ -1,5 +1,6 @@
// MakerNote tag IDs and values referenced from: // 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'; import type { ExifData } from 'ts-exif-parser';
@ -9,6 +10,7 @@ const BYTE_INDEX_TAG_COUNT = 12;
const BYTE_INDEX_FIRST_TAG = 14; const BYTE_INDEX_FIRST_TAG = 14;
const BYTES_PER_TAG = 12; const BYTES_PER_TAG = 12;
const BYTE_OFFSET_TAG_TYPE = 2; const BYTE_OFFSET_TAG_TYPE = 2;
const BYTE_OFFSET_TAG_SIZE = 4;
const BYTE_OFFSET_TAG_VALUE = 8; const BYTE_OFFSET_TAG_VALUE = 8;
export const isExifForFujifilm = (data: ExifData) => export const isExifForFujifilm = (data: ExifData) =>
@ -16,7 +18,7 @@ export const isExifForFujifilm = (data: ExifData) =>
export const parseFujifilmMakerNote = ( export const parseFujifilmMakerNote = (
bytes: Buffer, bytes: Buffer,
valueForTagUInt: (tagId: number, value: number) => void, valueForTagNumbers: (tagId: number, numbers: number[]) => void,
) => { ) => {
const tagCount = bytes.readUint16LE(BYTE_INDEX_TAG_COUNT); const tagCount = bytes.readUint16LE(BYTE_INDEX_TAG_COUNT);
for (let i = 0; i < tagCount; i++) { for (let i = 0; i < tagCount; i++) {
@ -24,22 +26,46 @@ export const parseFujifilmMakerNote = (
if (index + BYTES_PER_TAG < bytes.length) { if (index + BYTES_PER_TAG < bytes.length) {
const tagId = bytes.readUInt16LE(index); const tagId = bytes.readUInt16LE(index);
const tagType = bytes.readUInt16LE(index + BYTE_OFFSET_TAG_TYPE); const tagType = bytes.readUInt16LE(index + BYTE_OFFSET_TAG_TYPE);
const tagValueSize = bytes.readUInt16LE(index + BYTE_OFFSET_TAG_SIZE);
switch (tagType) { switch (tagType) {
// Int8 (UInt8 read as Int8)
case 1:
valueForTagNumbers(
tagId,
[bytes.readInt8(index + BYTE_OFFSET_TAG_VALUE)],
);
break;
// UInt16 // UInt16
case 3: case 3:
valueForTagUInt( valueForTagNumbers(
tagId, tagId,
bytes.readUInt16LE(index + BYTE_OFFSET_TAG_VALUE), [bytes.readUInt16LE(index + BYTE_OFFSET_TAG_VALUE)],
); );
break; break;
// UInt32 // UInt32
case 4: case 4:
valueForTagUInt( valueForTagNumbers(
tagId, tagId,
bytes.readUInt32LE(index + BYTE_OFFSET_TAG_VALUE), [bytes.readUInt32LE(index + BYTE_OFFSET_TAG_VALUE)],
); );
break; 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;
} }
} }
} }
}; };

View File

@ -1,46 +1,158 @@
import { parseFujifilmMakerNote } from '.'; 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_DEVELOPMENT_DYNAMIC_RANGE = 0x1403;
// const TAG_ID_AUTO_DYNAMIC_RANGE = 0x140b; const TAG_ID_HIGHLIGHT = 0x1041;
// const TAG_ID_HIGHLIGHT = 0x1041; const TAG_ID_SHADOW = 0x1040;
// const TAG_ID_SHADOW = 0x1040; const TAG_ID_SATURATION = 0x1003;
// const TAG_ID_COLOR = 0x1003; const TAG_ID_NOISE_REDUCTION = 0x100e;
// const TAG_ID_NOISE_REDUCTION = 0x100b; const TAG_ID_NOISE_REDUCTION_LEGACY = 0x100b;
// const TAG_ID_SHARPNESS = 0x1001; const TAG_ID_SHARPNESS = 0x1001;
// const TAG_ID_CLARITY = 0x100f; const TAG_ID_CLARITY = 0x100f;
// const TAG_ID_GRAIN_EFFECT_ROUGHNESS = 0x1047; const TAG_ID_GRAIN_EFFECT_ROUGHNESS = 0x1047;
// const TAG_ID_GRAIN_EFFECT_SIZE = 0x104c; const TAG_ID_GRAIN_EFFECT_SIZE = 0x104c;
// const TAG_ID_COLOR_CHROME_EFFECT = 0x1048; const TAG_ID_COLOR_CHROME_EFFECT = 0x1048;
// const TAG_ID_COLOR_CHROME_FX_BLUE = 0x104e; const TAG_ID_COLOR_CHROME_FX_BLUE = 0x104e;
// const TAG_ID_WHITE_BALANCE = 0x1002; const TAG_ID_WHITE_BALANCE = 0x1002;
// const TAG_ID_WHITE_BALANCE_FINE_TUNE = 0x1003; const TAG_ID_WHITE_BALANCE_FINE_TUNE = 0x100a;
// TBD const TAG_ID_BW_ADJUSTMENT = 0x1049;
// const TAG_ID_TONE = 0x1004; const TAG_ID_BW_MAGENTA_GREEN = 0x104b;
// const TAG_ID_CONTRAST = 0x1006;
type WeakStrong = 'off' | 'weak' | 'strong';
export interface FujifilmRecipe { export interface FujifilmRecipe {
dynamicRange: number dynamicRange: number
highlight: number highlight: number
shadow: number shadow: number
color: number color: number
noiseReduction: number highISONoiseReduction: number
noiseReductionLegacy: string
sharpness: number sharpness: number
clarity: number clarity: number
grainEffect: { grainEffect: {
roughness: 'strong' | 'medium' | 'weak' roughness: WeakStrong
size: 'small' | 'large' size: 'off' | 'small' | 'large'
} }
colorChromeEffect: 'strong' | 'medium' | 'weak' colorChromeEffect: WeakStrong
colorChromeFXBlue: 'off' | 'weak' | 'strong' colorChromeFXBlue: WeakStrong
whiteBalance: { whiteBalance: {
type: string type: string
red: number red: number
blue: 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 = ( export const getFujifilmRecipeFromMakerNote = (
bytes: Buffer, bytes: Buffer,
): Partial<FujifilmRecipe> => { ): Partial<FujifilmRecipe> => {
@ -48,10 +160,65 @@ export const getFujifilmRecipeFromMakerNote = (
parseFujifilmMakerNote( parseFujifilmMakerNote(
bytes, bytes,
(tag, value) => { (tag, numbers) => {
switch (tag) { switch (tag) {
case TAG_ID_DEVELOPMENT_DYNAMIC_RANGE: 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; break;
} }
}, },

View File

@ -225,13 +225,15 @@ export const getFujifilmSimulationFromMakerNote = (
parseFujifilmMakerNote( parseFujifilmMakerNote(
bytes, bytes,
(tag, value) => { (tag, numbers) => {
switch (tag) { switch (tag) {
case TAG_ID_SATURATION: case TAG_ID_SATURATION:
filmModeFromSaturation = getFujifilmSimulationFromSaturation(value); filmModeFromSaturation =
getFujifilmSimulationFromSaturation(numbers[0]);
break; break;
case TAG_ID_FILM_MODE: case TAG_ID_FILM_MODE:
filmMode = getFujifilmMode(value); filmMode =
getFujifilmMode(numbers[0]);
break; break;
} }
}, },