Add parsing for remaining fujifilm recipe fields
This commit is contained in:
parent
c9ffb96082
commit
64a49c85a3
16
__tests__/fujifilm.test.ts
Normal file
16
__tests__/fujifilm.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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',
|
||||
|
||||
@ -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]);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,21 +26,45 @@ 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
},
|
||||
|
||||
@ -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;
|
||||
}
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user