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 [
'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',

View File

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

View File

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

View File

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

View File

@ -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<FujifilmRecipe> => {
@ -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;
}
},

View File

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