From 8249e2929b64657b727c92d3a5854d802e77608c Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Tue, 18 Feb 2025 22:53:18 -0600 Subject: [PATCH 01/38] Create initial fujifilm recipe type --- app/film-demo/animate/page.tsx | 7 +-- app/film-demo/page.tsx | 7 +-- src/app/CommandK.tsx | 2 +- .../FilmSimulationImageResponse.tsx | 2 +- src/photo/form/index.ts | 4 +- src/photo/server.ts | 4 +- src/platforms/fujifilm/index.ts | 48 +++++++++++++++++ src/platforms/fujifilm/recipe.ts | 20 +++++++ .../{fujifilm.ts => fujifilm/simulation.ts} | 53 ++----------------- src/simulation/PhotoFilmSimulation.tsx | 2 +- src/simulation/PhotoFilmSimulationIcon.tsx | 2 +- src/simulation/index.ts | 2 +- 12 files changed, 90 insertions(+), 63 deletions(-) create mode 100644 src/platforms/fujifilm/index.ts create mode 100644 src/platforms/fujifilm/recipe.ts rename src/platforms/{fujifilm.ts => fujifilm/simulation.ts} (80%) diff --git a/app/film-demo/animate/page.tsx b/app/film-demo/animate/page.tsx index 11612df7..ba877ed0 100644 --- a/app/film-demo/animate/page.tsx +++ b/app/film-demo/animate/page.tsx @@ -2,9 +2,10 @@ import SiteGrid from '@/components/SiteGrid'; import { clsx } from 'clsx/lite'; -import { FILM_SIMULATION_FORM_INPUT_OPTIONS } from '@/platforms/fujifilm'; -import PhotoFilmSimulation from - '@/simulation/PhotoFilmSimulation'; +import { + FILM_SIMULATION_FORM_INPUT_OPTIONS, +} from '@/platforms/fujifilm/simulation'; +import PhotoFilmSimulation from '@/simulation/PhotoFilmSimulation'; import { useEffect, useState } from 'react'; export default function FilmPage() { diff --git a/app/film-demo/page.tsx b/app/film-demo/page.tsx index a5e7467c..cb5724c1 100644 --- a/app/film-demo/page.tsx +++ b/app/film-demo/page.tsx @@ -1,6 +1,7 @@ -import { FILM_SIMULATION_FORM_INPUT_OPTIONS } from '@/platforms/fujifilm'; -import PhotoFilmSimulation from - '@/simulation/PhotoFilmSimulation'; +import { + FILM_SIMULATION_FORM_INPUT_OPTIONS, +} from '@/platforms/fujifilm/simulation'; +import PhotoFilmSimulation from '@/simulation/PhotoFilmSimulation'; export default function FilmPage() { return ( diff --git a/src/app/CommandK.tsx b/src/app/CommandK.tsx index 0df5f312..7d239adc 100644 --- a/src/app/CommandK.tsx +++ b/src/app/CommandK.tsx @@ -18,7 +18,7 @@ import { formatCount, formatCountDescriptive } from '@/utility/string'; import PhotoFilmSimulationIcon from '@/simulation/PhotoFilmSimulationIcon'; import { IoMdCamera } from 'react-icons/io'; import { ADMIN_DEBUG_TOOLS_ENABLED, SHOW_FILM_SIMULATIONS } from './config'; -import { labelForFilmSimulation } from '@/platforms/fujifilm'; +import { labelForFilmSimulation } from '@/platforms/fujifilm/simulation'; import { getUniqueFocalLengths } from '@/photo/db/query'; import { formatFocalLength } from '@/focal'; import { TbCone } from 'react-icons/tb'; diff --git a/src/image-response/FilmSimulationImageResponse.tsx b/src/image-response/FilmSimulationImageResponse.tsx index 3b458146..f83af711 100644 --- a/src/image-response/FilmSimulationImageResponse.tsx +++ b/src/image-response/FilmSimulationImageResponse.tsx @@ -4,7 +4,7 @@ import ImagePhotoGrid from './components/ImagePhotoGrid'; import ImageContainer from './components/ImageContainer'; import { labelForFilmSimulation, -} from '@/platforms/fujifilm'; +} from '@/platforms/fujifilm/simulation'; import PhotoFilmSimulationIcon from '@/simulation/PhotoFilmSimulationIcon'; import { FilmSimulation } from '@/simulation'; diff --git a/src/photo/form/index.ts b/src/photo/form/index.ts index 83991340..c07997ee 100644 --- a/src/photo/form/index.ts +++ b/src/photo/form/index.ts @@ -18,11 +18,11 @@ import { convertStringToArray } from '@/utility/string'; import { generateNanoid } from '@/utility/nanoid'; import { FILM_SIMULATION_FORM_INPUT_OPTIONS, - MAKE_FUJIFILM, -} from '@/platforms/fujifilm'; +} from '@/platforms/fujifilm/simulation'; import { FilmSimulation } from '@/simulation'; import { GEO_PRIVACY_ENABLED } from '@/app/config'; import { TAG_FAVS, getValidationMessageForTags } from '@/tag'; +import { MAKE_FUJIFILM } from '@/platforms/fujifilm'; type VirtualFields = 'favorite'; diff --git a/src/photo/server.ts b/src/photo/server.ts index 5a368db1..57a8fbef 100644 --- a/src/photo/server.ts +++ b/src/photo/server.ts @@ -5,8 +5,7 @@ import { import { convertExifToFormData } from '@/photo/form'; import { getFujifilmSimulationFromMakerNote, - isExifForFujifilm, -} from '@/platforms/fujifilm'; +} from '@/platforms/fujifilm/simulation'; import { ExifData, ExifParserFactory } from 'ts-exif-parser'; import { PhotoFormData } from './form'; import { FilmSimulation } from '@/simulation'; @@ -15,6 +14,7 @@ import { GEO_PRIVACY_ENABLED, PRESERVE_ORIGINAL_UPLOADS, } from '@/app/config'; +import { isExifForFujifilm } from '@/platforms/fujifilm'; const IMAGE_WIDTH_RESIZE = 200; const IMAGE_WIDTH_BLUR = 200; diff --git a/src/platforms/fujifilm/index.ts b/src/platforms/fujifilm/index.ts new file mode 100644 index 00000000..250b54e0 --- /dev/null +++ b/src/platforms/fujifilm/index.ts @@ -0,0 +1,48 @@ +// MakerNote tag IDs and values referenced from: +// github.com/exiftool/exiftool/blob/master/lib/Image/ExifTool/FujiFilm.pm + +import type { ExifData } from 'ts-exif-parser'; + +export const MAKE_FUJIFILM = 'FUJIFILM'; + +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_VALUE = 8; + +export const TAG_ID_SATURATION = 0x1003; +export const TAG_ID_FILM_MODE = 0x1401; + +export const isExifForFujifilm = (data: ExifData) => + data.tags?.Make === MAKE_FUJIFILM; + +export const parseFujifilmMakerNote = ( + bytes: Buffer, + valueForTagUInt: (tagId: number, value: number) => void, +) => { + const tagCount = bytes.readUint16LE(BYTE_INDEX_TAG_COUNT); + for (let i = 0; i < tagCount; i++) { + const index = BYTE_INDEX_FIRST_TAG + i * BYTES_PER_TAG; + if (index + BYTES_PER_TAG < bytes.length) { + const tagId = bytes.readUInt16LE(index); + const tagType = bytes.readUInt16LE(index + BYTE_OFFSET_TAG_TYPE); + switch (tagType) { + // UInt16 + case 3: + valueForTagUInt( + tagId, + bytes.readUInt16LE(index + BYTE_OFFSET_TAG_VALUE), + ); + break; + // UInt32 + case 4: + valueForTagUInt( + tagId, + bytes.readUInt32LE(index + BYTE_OFFSET_TAG_VALUE), + ); + break; + } + } + } +}; \ No newline at end of file diff --git a/src/platforms/fujifilm/recipe.ts b/src/platforms/fujifilm/recipe.ts new file mode 100644 index 00000000..9a2962c0 --- /dev/null +++ b/src/platforms/fujifilm/recipe.ts @@ -0,0 +1,20 @@ +export interface FujifilmRecipe { + dynamicRange: number + highlight: number + shadow: number + color: number + noiseReduction: number + sharpening: number + clarity: number + grainEffect: { + type: 'strong' | 'medium' | 'weak' + size: 'small' | 'large' + } + colorChromeEffect: 'strong' | 'medium' | 'weak' + colorChromeEffectBlue: 'off' | 'weak' | 'strong' + whiteBalance: { + type: string + red: number + blue: number + } +} diff --git a/src/platforms/fujifilm.ts b/src/platforms/fujifilm/simulation.ts similarity index 80% rename from src/platforms/fujifilm.ts rename to src/platforms/fujifilm/simulation.ts index 6411765c..12ac0438 100644 --- a/src/platforms/fujifilm.ts +++ b/src/platforms/fujifilm/simulation.ts @@ -1,18 +1,8 @@ -// MakerNote tag IDs and values referenced from: -// github.com/exiftool/exiftool/blob/master/lib/Image/ExifTool/FujiFilm.pm - -import type { ExifData } from 'ts-exif-parser'; - -export const MAKE_FUJIFILM = 'FUJIFILM'; - -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_VALUE = 8; - -const TAG_ID_SATURATION = 0x1003; -const TAG_ID_FILM_MODE = 0x1401; +import { + TAG_ID_FILM_MODE, + parseFujifilmMakerNote, + TAG_ID_SATURATION, +} from '.'; type FujifilmSimulationFromSaturation = 'monochrome' | @@ -46,9 +36,6 @@ export type FujifilmSimulation = FujifilmSimulationFromSaturation | FujifilmMode; -export const isExifForFujifilm = (data: ExifData) => - data.tags?.Make === MAKE_FUJIFILM; - const getFujifilmSimulationFromSaturation = ( value?: number, ): FujifilmSimulationFromSaturation | undefined => { @@ -231,36 +218,6 @@ export const FILM_SIMULATION_FORM_INPUT_OPTIONS = Object export const labelForFilmSimulation = (simulation: FujifilmSimulation) => FILM_SIMULATION_LABELS[simulation]; -const parseFujifilmMakerNote = ( - bytes: Buffer, - valueForTagUInt: (tagId: number, value: number) => void, -) => { - const tagCount = bytes.readUint16LE(BYTE_INDEX_TAG_COUNT); - for (let i = 0; i < tagCount; i++) { - const index = BYTE_INDEX_FIRST_TAG + i * BYTES_PER_TAG; - if (index + BYTES_PER_TAG < bytes.length) { - const tagId = bytes.readUInt16LE(index); - const tagType = bytes.readUInt16LE(index + BYTE_OFFSET_TAG_TYPE); - switch (tagType) { - // UInt16 - case 3: - valueForTagUInt( - tagId, - bytes.readUInt16LE(index + BYTE_OFFSET_TAG_VALUE), - ); - break; - // UInt32 - case 4: - valueForTagUInt( - tagId, - bytes.readUInt32LE(index + BYTE_OFFSET_TAG_VALUE), - ); - break; - } - } - } -}; - export const getFujifilmSimulationFromMakerNote = ( bytes: Buffer, ): FujifilmSimulation | undefined => { diff --git a/src/simulation/PhotoFilmSimulation.tsx b/src/simulation/PhotoFilmSimulation.tsx index e6c03f72..e1e9139a 100644 --- a/src/simulation/PhotoFilmSimulation.tsx +++ b/src/simulation/PhotoFilmSimulation.tsx @@ -1,4 +1,4 @@ -import { labelForFilmSimulation } from '@/platforms/fujifilm'; +import { labelForFilmSimulation } from '@/platforms/fujifilm/simulation'; import PhotoFilmSimulationIcon from './PhotoFilmSimulationIcon'; import { pathForFilmSimulation } from '@/app/paths'; import { FilmSimulation } from '.'; diff --git a/src/simulation/PhotoFilmSimulationIcon.tsx b/src/simulation/PhotoFilmSimulationIcon.tsx index 230d8c96..9c05d18d 100644 --- a/src/simulation/PhotoFilmSimulationIcon.tsx +++ b/src/simulation/PhotoFilmSimulationIcon.tsx @@ -1,5 +1,5 @@ /* eslint-disable max-len */ -import { labelForFilmSimulation } from '@/platforms/fujifilm'; +import { labelForFilmSimulation } from '@/platforms/fujifilm/simulation'; import { CSSProperties } from 'react'; import { FilmSimulation } from '.'; diff --git a/src/simulation/index.ts b/src/simulation/index.ts index 499660c0..5b283887 100644 --- a/src/simulation/index.ts +++ b/src/simulation/index.ts @@ -11,7 +11,7 @@ import { import { FujifilmSimulation, labelForFilmSimulation, -} from '@/platforms/fujifilm'; +} from '@/platforms/fujifilm/simulation'; export type FilmSimulation = FujifilmSimulation; From c9ffb96082b368638067440907402b4bb39da240 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Tue, 18 Feb 2025 23:22:27 -0600 Subject: [PATCH 02/38] Parse dynamic range for recipe --- src/photo/server.ts | 4 +++ src/platforms/fujifilm/index.ts | 3 -- src/platforms/fujifilm/recipe.ts | 47 ++++++++++++++++++++++++++-- src/platforms/fujifilm/simulation.ts | 9 +++--- 4 files changed, 52 insertions(+), 11 deletions(-) diff --git a/src/photo/server.ts b/src/photo/server.ts index 57a8fbef..8f77b66d 100644 --- a/src/photo/server.ts +++ b/src/photo/server.ts @@ -15,6 +15,7 @@ import { PRESERVE_ORIGINAL_UPLOADS, } from '@/app/config'; import { isExifForFujifilm } from '@/platforms/fujifilm'; +import { getFujifilmRecipeFromMakerNote } from '@/platforms/fujifilm/recipe'; const IMAGE_WIDTH_RESIZE = 200; const IMAGE_WIDTH_BLUR = 200; @@ -78,6 +79,9 @@ export const extractImageDataFromBlobPath = async ( const makerNote = exifDataBinary.tags?.MakerNote; if (Buffer.isBuffer(makerNote)) { filmSimulation = getFujifilmSimulationFromMakerNote(makerNote); + console.log({ + recipe: getFujifilmRecipeFromMakerNote(makerNote), + }); } } diff --git a/src/platforms/fujifilm/index.ts b/src/platforms/fujifilm/index.ts index 250b54e0..485a5f1d 100644 --- a/src/platforms/fujifilm/index.ts +++ b/src/platforms/fujifilm/index.ts @@ -11,9 +11,6 @@ const BYTES_PER_TAG = 12; const BYTE_OFFSET_TAG_TYPE = 2; const BYTE_OFFSET_TAG_VALUE = 8; -export const TAG_ID_SATURATION = 0x1003; -export const TAG_ID_FILM_MODE = 0x1401; - export const isExifForFujifilm = (data: ExifData) => data.tags?.Make === MAKE_FUJIFILM; diff --git a/src/platforms/fujifilm/recipe.ts b/src/platforms/fujifilm/recipe.ts index 9a2962c0..099232c9 100644 --- a/src/platforms/fujifilm/recipe.ts +++ b/src/platforms/fujifilm/recipe.ts @@ -1,20 +1,61 @@ +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; + export interface FujifilmRecipe { dynamicRange: number highlight: number shadow: number color: number noiseReduction: number - sharpening: number + sharpness: number clarity: number grainEffect: { - type: 'strong' | 'medium' | 'weak' + roughness: 'strong' | 'medium' | 'weak' size: 'small' | 'large' } colorChromeEffect: 'strong' | 'medium' | 'weak' - colorChromeEffectBlue: 'off' | 'weak' | 'strong' + colorChromeFXBlue: 'off' | 'weak' | 'strong' whiteBalance: { type: string red: number blue: number } } + +export const getFujifilmRecipeFromMakerNote = ( + bytes: Buffer, +): Partial => { + const recipe: Partial = {}; + + parseFujifilmMakerNote( + bytes, + (tag, value) => { + switch (tag) { + case TAG_ID_DEVELOPMENT_DYNAMIC_RANGE: + recipe.dynamicRange = value; + break; + } + }, + ); + + return recipe; +}; diff --git a/src/platforms/fujifilm/simulation.ts b/src/platforms/fujifilm/simulation.ts index 12ac0438..63391284 100644 --- a/src/platforms/fujifilm/simulation.ts +++ b/src/platforms/fujifilm/simulation.ts @@ -1,8 +1,7 @@ -import { - TAG_ID_FILM_MODE, - parseFujifilmMakerNote, - TAG_ID_SATURATION, -} from '.'; +import { parseFujifilmMakerNote } from '.'; + +const TAG_ID_SATURATION = 0x1003; +const TAG_ID_FILM_MODE = 0x1401; type FujifilmSimulationFromSaturation = 'monochrome' | From 64a49c85a3a67b19f54529119d8be6b8bdb71ca8 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Wed, 19 Feb 2025 17:18:59 -0600 Subject: [PATCH 03/38] 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; } }, From faad28e6f7e85aa850beca617b8351a759cf19db Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Wed, 19 Feb 2025 18:12:01 -0600 Subject: [PATCH 04/38] Add recipe to db model, refactor migrations --- src/photo/db/migration.ts | 40 +++++++++++++++++++++++++++++++++++++++ src/photo/db/query.ts | 40 +++++++++------------------------------ src/photo/form/index.ts | 13 ++++++++++++- src/photo/index.ts | 7 ++++++- src/photo/server.ts | 3 +-- 5 files changed, 68 insertions(+), 35 deletions(-) create mode 100644 src/photo/db/migration.ts diff --git a/src/photo/db/migration.ts b/src/photo/db/migration.ts new file mode 100644 index 00000000..eb29ee49 --- /dev/null +++ b/src/photo/db/migration.ts @@ -0,0 +1,40 @@ +import { sql } from '@/platforms/postgres'; + +interface Migration { + label: string + fields: string[] + run: () => ReturnType +} + +export const MIGRATIONS: Migration[] = [{ + label: '01: AI Text Generation', + fields: ['caption', 'semantic_description'], + run: () => sql` + ALTER TABLE photos + ADD COLUMN IF NOT EXISTS caption TEXT, + ADD COLUMN IF NOT EXISTS semantic_description TEXT + `, +}, { + label: '02: Lens Metadata', + fields: ['lens_make', 'lens_model'], + run: () => sql` + ALTER TABLE photos + ADD COLUMN IF NOT EXISTS lens_make VARCHAR(255), + ADD COLUMN IF NOT EXISTS lens_model VARCHAR(255) + `, +}, { + label: '03: Fujifilm Recipe', + fields: ['fujifilm_recipe'], + run: () => sql` + ALTER TABLE photos + ADD COLUMN IF NOT EXISTS fujifilm_recipe JSONB + `, +}]; + +export const migrationForError = (e: any) => + MIGRATIONS.find(migration => + migration.fields.some(field => + new RegExp(`column "${field}" of relation "photos" does not exist`, 'i') + .test(e.message), + ), + ); \ No newline at end of file diff --git a/src/photo/db/query.ts b/src/photo/db/query.ts index 1d783465..d339196b 100644 --- a/src/photo/db/query.ts +++ b/src/photo/db/query.ts @@ -23,6 +23,7 @@ import { import { getWheresFromOptions } from '.'; import { FocalLengths } from '@/focal'; import { Lenses, createLensKey } from '@/lens'; +import { migrationForError } from './migration'; const createPhotosTable = () => sql` @@ -50,6 +51,7 @@ const createPhotosTable = () => latitude DOUBLE PRECISION, longitude DOUBLE PRECISION, film_simulation VARCHAR(255), + fujifilm_recipe JSONB, priority_order REAL, taken_at TIMESTAMP WITH TIME ZONE NOT NULL, taken_at_naive VARCHAR(255) NOT NULL, @@ -59,24 +61,6 @@ const createPhotosTable = () => ) `; -// Migration 01 -const MIGRATION_FIELDS_01 = ['caption', 'semantic_description']; -const runMigration01 = () => - sql` - ALTER TABLE photos - ADD COLUMN IF NOT EXISTS caption TEXT, - ADD COLUMN IF NOT EXISTS semantic_description TEXT - `; - -// Migration 02 -const MIGRATION_FIELDS_02 = ['lens_make', 'lens_model']; -const runMigration02 = () => - sql` - ALTER TABLE photos - ADD COLUMN IF NOT EXISTS lens_make VARCHAR(255), - ADD COLUMN IF NOT EXISTS lens_model VARCHAR(255) - `; - // Wrapper for most queries for JIT table creation/migration running const safelyQueryPhotos = async ( callback: () => Promise, @@ -89,19 +73,10 @@ const safelyQueryPhotos = async ( try { result = await callback(); } catch (e: any) { - if (MIGRATION_FIELDS_01.some(field => new RegExp( - `column "${field}" of relation "photos" does not exist`, - 'i', - ).test(e.message))) { - console.log('Running migration 01 ...'); - await runMigration01(); - result = await callback(); - } else if (MIGRATION_FIELDS_02.some(field => new RegExp( - `column "${field}" of relation "photos" does not exist`, - 'i', - ).test(e.message))) { - console.log('Running migration 02 ...'); - await runMigration02(); + const migration = migrationForError(e); + if (migration) { + console.log(`Running Migration ${migration.label} ...`); + await migration.run(); result = await callback(); } else if (/relation "photos" does not exist/i.test(e.message)) { // If the table does not exist, create it @@ -163,6 +138,7 @@ export const insertPhoto = (photo: PhotoDbInsert) => latitude, longitude, film_simulation, + fujifilm_recipe, priority_order, hidden, taken_at, @@ -192,6 +168,7 @@ export const insertPhoto = (photo: PhotoDbInsert) => ${photo.latitude}, ${photo.longitude}, ${photo.filmSimulation}, + ${JSON.stringify(photo.fujifilmRecipe)}, ${photo.priorityOrder}, ${photo.hidden}, ${photo.takenAt}, @@ -224,6 +201,7 @@ export const updatePhoto = (photo: PhotoDbInsert) => latitude=${photo.latitude}, longitude=${photo.longitude}, film_simulation=${photo.filmSimulation}, + fujifilm_recipe=${JSON.stringify(photo.fujifilmRecipe)}, priority_order=${photo.priorityOrder || null}, hidden=${photo.hidden}, taken_at=${photo.takenAt}, diff --git a/src/photo/form/index.ts b/src/photo/form/index.ts index c07997ee..5311e546 100644 --- a/src/photo/form/index.ts +++ b/src/photo/form/index.ts @@ -23,6 +23,7 @@ import { FilmSimulation } from '@/simulation'; import { GEO_PRIVACY_ENABLED } from '@/app/config'; import { TAG_FAVS, getValidationMessageForTags } from '@/tag'; import { MAKE_FUJIFILM } from '@/platforms/fujifilm'; +import { FujifilmRecipe } from '@/platforms/fujifilm/recipe'; type VirtualFields = 'favorite'; @@ -107,6 +108,11 @@ const FORM_METADATA = ( selectOptionsDefaultLabel: 'Unknown', shouldHide: ({ make }) => make !== MAKE_FUJIFILM, }, + fujifilmRecipe: { + type: 'textarea', + label: 'fujifilm recipe', + shouldHide: ({ make }) => make !== MAKE_FUJIFILM, + }, focalLength: { label: 'focal length' }, focalLengthIn35MmFormat: { label: 'focal length 35mm-equivalent' }, lensMake: { label: 'lens make' }, @@ -200,6 +206,7 @@ export const convertPhotoToFormData = ( export const convertExifToFormData = ( data: ExifData, filmSimulation?: FilmSimulation, + fujifilmRecipe?: Partial, ): Omit< Record, 'takenAt' | 'takenAtNaive' @@ -223,6 +230,7 @@ export const convertExifToFormData = ( longitude: !GEO_PRIVACY_ENABLED ? data.tags?.GPSLongitude?.toString() : undefined, filmSimulation, + fujifilmRecipe: JSON.stringify(fujifilmRecipe), ...data.tags?.DateTimeOriginal && { takenAt: convertTimestampWithOffsetToPostgresString( data.tags.DateTimeOriginal, @@ -267,7 +275,10 @@ export const convertFormDataToPhotoDbInsert = ( }); return { - ...(photoForm as PhotoFormData & { filmSimulation?: FilmSimulation }), + ...(photoForm as PhotoFormData & { + filmSimulation?: FilmSimulation + fujifilmRecipe?: FujifilmRecipe + }), ...!photoForm.id && { id: generateNanoid() }, // Delete array field when empty tags: tags.length > 0 ? tags : undefined, diff --git a/src/photo/index.ts b/src/photo/index.ts index 83597475..47b35a68 100644 --- a/src/photo/index.ts +++ b/src/photo/index.ts @@ -20,6 +20,7 @@ import { parameterize } from '@/utility/string'; import camelcaseKeys from 'camelcase-keys'; import { isBefore } from 'date-fns'; import type { Metadata } from 'next'; +import { FujifilmRecipe } from '@/platforms/fujifilm/recipe'; export const OUTDATED_THRESHOLD = new Date('2024-06-16'); @@ -66,6 +67,7 @@ export interface PhotoExif { latitude?: number longitude?: number filmSimulation?: FilmSimulation + fujifilmRecipe?: string takenAt?: string takenAtNaive?: string } @@ -88,11 +90,13 @@ export interface PhotoDbInsert extends PhotoExif { } // Raw db response -export interface PhotoDb extends Omit { +export interface PhotoDb extends + Omit { updatedAt: Date createdAt: Date takenAt: Date tags: string[] + fujifilmRecipe?: Partial } // Parsed db response @@ -159,6 +163,7 @@ export const convertPhotoToPhotoDbInsert = ( ): PhotoDbInsert => ({ ...photo, takenAt: photo.takenAt.toISOString(), + fujifilmRecipe: JSON.stringify(photo.fujifilmRecipe), }); export const photoStatsAsString = (photo: Photo) => [ diff --git a/src/photo/server.ts b/src/photo/server.ts index e9c94cf4..79d8fd76 100644 --- a/src/photo/server.ts +++ b/src/photo/server.ts @@ -83,7 +83,6 @@ export const extractImageDataFromBlobPath = async ( if (Buffer.isBuffer(makerNote)) { filmSimulation = getFujifilmSimulationFromMakerNote(makerNote); recipe = getFujifilmRecipeFromMakerNote(makerNote); - console.log(recipe); } } @@ -117,7 +116,7 @@ export const extractImageDataFromBlobPath = async ( url, }, ...generateBlurData && { blurData }, - ...convertExifToFormData(exifData, filmSimulation), + ...convertExifToFormData(exifData, filmSimulation, recipe), }, }, imageResizedBase64, From a63c05a5027adbd1ba994f778bf8789b4a8c1cc1 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Wed, 19 Feb 2025 19:34:16 -0600 Subject: [PATCH 05/38] Fix migration label typo --- src/photo/db/migration.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/photo/db/migration.ts b/src/photo/db/migration.ts index eb29ee49..dde987b4 100644 --- a/src/photo/db/migration.ts +++ b/src/photo/db/migration.ts @@ -23,7 +23,7 @@ export const MIGRATIONS: Migration[] = [{ ADD COLUMN IF NOT EXISTS lens_model VARCHAR(255) `, }, { - label: '03: Fujifilm Recipe', + label: '03: Fujifilm Recipe', fields: ['fujifilm_recipe'], run: () => sql` ALTER TABLE photos From 62a681a4247c53158e316d9417b8c1b858ea02e5 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Wed, 19 Feb 2025 20:34:31 -0600 Subject: [PATCH 06/38] Display basic fujifilm recipes --- src/photo/PhotoLarge.tsx | 14 +++-- src/photo/PhotoRecipe.tsx | 93 ++++++++++++++++++++++++++++++++ src/photo/form/index.ts | 4 +- src/photo/index.ts | 9 ++-- src/photo/server.ts | 2 +- src/platforms/fujifilm/recipe.ts | 16 +++--- 6 files changed, 121 insertions(+), 17 deletions(-) create mode 100644 src/photo/PhotoRecipe.tsx diff --git a/src/photo/PhotoLarge.tsx b/src/photo/PhotoLarge.tsx index 7f5beb3a..c76f77cc 100644 --- a/src/photo/PhotoLarge.tsx +++ b/src/photo/PhotoLarge.tsx @@ -40,6 +40,7 @@ import { LuExpand } from 'react-icons/lu'; import LoaderButton from '@/components/primitives/LoaderButton'; import Tooltip from '@/components/Tooltip'; import ZoomControls, { ZoomControlsRef } from '@/components/image/ZoomControls'; +import PhotoRecipe from './PhotoRecipe'; export default function PhotoLarge({ photo, @@ -275,10 +276,15 @@ export default function PhotoLarge({
  • {photo.exposureCompensationFormatted ?? '0ev'}
  • {showSimulation && photo.filmSimulation && - } + + : undefined + }> + + } }
    value < 0 ? value : `+${value}`; + +export default function PhotoRecipe({ recipe: { + dynamicRange, + highlight, + shadow, + color, + highISONoiseReduction, + noiseReductionLegacy, + sharpness, + clarity, + grainEffect, + colorChromeEffect, + colorChromeFXBlue, + whiteBalance, + bwAdjustment, + bwMagentaGreen, +} }: { recipe: FujifilmRecipe }) { + return
    +
    + Fujifilm Recipe +
    +
    + {dynamicRange !== undefined && <> +
    DR
    +
    {dynamicRange}
    + } + {highlight !== undefined && <> +
    Highlight
    +
    {addSign(highlight)}
    + } + {shadow !== undefined && <> +
    Shadow
    +
    {addSign(shadow)}
    + } + {color !== undefined && <> +
    Color
    +
    {addSign(color)}
    + } + {highISONoiseReduction !== undefined && <> +
    High ISO NR
    +
    {addSign(highISONoiseReduction)}
    + } + {noiseReductionLegacy !== undefined && <> +
    NR
    +
    {noiseReductionLegacy}
    + } + {sharpness !== undefined && <> +
    Sharpness
    +
    {addSign(sharpness)}
    + } + {clarity !== undefined && <> +
    Clarity
    +
    {addSign(clarity)}
    + } + {grainEffect !== undefined && <> +
    Grain
    +
    {grainEffect.roughness} / {grainEffect.size}
    + } + {colorChromeEffect !== undefined && <> +
    Chrome
    +
    {colorChromeEffect}
    + } + {colorChromeFXBlue !== undefined && <> +
    Chrome FX Blue
    +
    {colorChromeFXBlue}
    + } + {whiteBalance !== undefined && <> +
    White Balance
    +
    +
    {whiteBalance.type}
    +
    + {addSign(whiteBalance.red)} / {addSign(whiteBalance.blue)} +
    +
    + } + {bwAdjustment !== undefined && <> +
    BW
    +
    {addSign(bwAdjustment)}
    + } + {bwMagentaGreen !== undefined && <> +
    BW MG
    +
    {addSign(bwMagentaGreen)}
    + } +
    +
    ; +} diff --git a/src/photo/form/index.ts b/src/photo/form/index.ts index 5311e546..cfe37de3 100644 --- a/src/photo/form/index.ts +++ b/src/photo/form/index.ts @@ -187,6 +187,8 @@ export const convertPhotoToFormData = ( return value?.toISOString ? value.toISOString() : value; case 'hidden': return value ? 'true' : 'false'; + case 'fujifilmRecipe': + return JSON.stringify(value); default: return value !== undefined && value !== null ? value.toString() @@ -206,7 +208,7 @@ export const convertPhotoToFormData = ( export const convertExifToFormData = ( data: ExifData, filmSimulation?: FilmSimulation, - fujifilmRecipe?: Partial, + fujifilmRecipe?: FujifilmRecipe, ): Omit< Record, 'takenAt' | 'takenAtNaive' diff --git a/src/photo/index.ts b/src/photo/index.ts index 47b35a68..57c3f93e 100644 --- a/src/photo/index.ts +++ b/src/photo/index.ts @@ -91,16 +91,15 @@ export interface PhotoDbInsert extends PhotoExif { // Raw db response export interface PhotoDb extends - Omit { + Omit { updatedAt: Date createdAt: Date takenAt: Date tags: string[] - fujifilmRecipe?: Partial } // Parsed db response -export interface Photo extends PhotoDb { +export interface Photo extends Omit { focalLengthFormatted?: string focalLengthIn35MmFormatFormatted?: string fNumberFormatted?: string @@ -108,6 +107,7 @@ export interface Photo extends PhotoDb { exposureTimeFormatted?: string exposureCompensationFormatted?: string takenAtNaiveFormatted: string + fujifilmRecipe?: FujifilmRecipe } export interface PhotoSetCategory { @@ -143,6 +143,9 @@ export const parsePhotoFromDb = (photoDbRaw: PhotoDb): Photo => { formatExposureTime(photoDb.exposureTime), exposureCompensationFormatted: formatExposureCompensation(photoDb.exposureCompensation), + fujifilmRecipe: photoDb.fujifilmRecipe + ? JSON.parse(photoDb.fujifilmRecipe) + : undefined, takenAtNaiveFormatted: formatDateFromPostgresString(photoDb.takenAtNaive), }; diff --git a/src/photo/server.ts b/src/photo/server.ts index 79d8fd76..85eb594a 100644 --- a/src/photo/server.ts +++ b/src/photo/server.ts @@ -51,7 +51,7 @@ export const extractImageDataFromBlobPath = async ( let exifData: ExifData | undefined; let filmSimulation: FilmSimulation | undefined; - let recipe: Partial | undefined; + let recipe: FujifilmRecipe | undefined; let blurData: string | undefined; let imageResizedBase64: string | undefined; let shouldStripGpsData = false; diff --git a/src/platforms/fujifilm/recipe.ts b/src/platforms/fujifilm/recipe.ts index 8a75258d..1e5ed518 100644 --- a/src/platforms/fujifilm/recipe.ts +++ b/src/platforms/fujifilm/recipe.ts @@ -5,7 +5,7 @@ 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_NOISE_REDUCTION_BASIC = 0x100b; const TAG_ID_SHARPNESS = 0x1001; const TAG_ID_CLARITY = 0x100f; const TAG_ID_GRAIN_EFFECT_ROUGHNESS = 0x1047; @@ -19,7 +19,7 @@ const TAG_ID_BW_MAGENTA_GREEN = 0x104b; type WeakStrong = 'off' | 'weak' | 'strong'; -export interface FujifilmRecipe { +export type FujifilmRecipe = Partial<{ dynamicRange: number highlight: number shadow: number @@ -41,7 +41,7 @@ export interface FujifilmRecipe { } bwAdjustment: number bwMagentaGreen: number -} +}>; const DEFAULT_GRAIN_EFFECT = { roughness: 'off', @@ -75,7 +75,7 @@ export const processNoiseReductionLegacy = (value: number) => { switch (value) { case 0x40: return 'low'; case 0x80: return 'normal'; - default: return 'n/a'; + default: return 'n/a'; } }; @@ -119,7 +119,7 @@ export const processWeakStrong = (value: number): WeakStrong => { export const processGrainEffectSize = ( value: number, -): FujifilmRecipe['grainEffect']['size'] => { +): Required['grainEffect']['size'] => { switch (value) { case 16: return 'small'; case 32: return 'large'; @@ -155,8 +155,8 @@ export const processWhiteBalanceComponent = (value: number) => value / 20; export const getFujifilmRecipeFromMakerNote = ( bytes: Buffer, -): Partial => { - const recipe: Partial = {}; +): FujifilmRecipe => { + const recipe: FujifilmRecipe = {}; parseFujifilmMakerNote( bytes, @@ -177,7 +177,7 @@ export const getFujifilmRecipeFromMakerNote = ( case TAG_ID_NOISE_REDUCTION: recipe.highISONoiseReduction = processNoiseReduction(numbers[0]); break; - case TAG_ID_NOISE_REDUCTION_LEGACY: + case TAG_ID_NOISE_REDUCTION_BASIC: recipe.noiseReductionLegacy = processNoiseReductionLegacy(numbers[0]); break; From 4cc083840337b157145c9b5bf19e1e867525f8e2 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Wed, 19 Feb 2025 22:48:39 -0600 Subject: [PATCH 07/38] Add toggle-able recipes to simulations --- src/photo/PhotoLarge.tsx | 15 +++---- src/photo/PhotoRecipe.tsx | 12 +++--- src/simulation/PhotoFilmSimulation.tsx | 58 +++++++++++++++++++------- 3 files changed, 55 insertions(+), 30 deletions(-) diff --git a/src/photo/PhotoLarge.tsx b/src/photo/PhotoLarge.tsx index c76f77cc..95cfd16e 100644 --- a/src/photo/PhotoLarge.tsx +++ b/src/photo/PhotoLarge.tsx @@ -40,7 +40,6 @@ import { LuExpand } from 'react-icons/lu'; import LoaderButton from '@/components/primitives/LoaderButton'; import Tooltip from '@/components/Tooltip'; import ZoomControls, { ZoomControlsRef } from '@/components/image/ZoomControls'; -import PhotoRecipe from './PhotoRecipe'; export default function PhotoLarge({ photo, @@ -276,15 +275,11 @@ export default function PhotoLarge({
  • {photo.exposureCompensationFormatted ?? '0ev'}
  • {showSimulation && photo.filmSimulation && - - : undefined - }> - - } + } }
    -
    - Fujifilm Recipe -
    +} }: { recipe: FujifilmRecipe }) { + return
    {dynamicRange !== undefined && <> diff --git a/src/simulation/PhotoFilmSimulation.tsx b/src/simulation/PhotoFilmSimulation.tsx index e1e9139a..7c3bda3a 100644 --- a/src/simulation/PhotoFilmSimulation.tsx +++ b/src/simulation/PhotoFilmSimulation.tsx @@ -1,11 +1,18 @@ +'use client'; + import { labelForFilmSimulation } from '@/platforms/fujifilm/simulation'; import PhotoFilmSimulationIcon from './PhotoFilmSimulationIcon'; import { pathForFilmSimulation } from '@/app/paths'; import { FilmSimulation } from '.'; +import { FujifilmRecipe } from '@/platforms/fujifilm/recipe'; import EntityLink, { EntityLinkExternalProps, } from '@/components/primitives/EntityLink'; - +import { LuChevronsUpDown } from 'react-icons/lu'; +import clsx from 'clsx'; +import { useState } from 'react'; +import PhotoRecipe from '@/photo/PhotoRecipe'; +import Tooltip from '@/components/Tooltip'; export default function PhotoFilmSimulation({ simulation, type = 'icon-last', @@ -13,25 +20,48 @@ export default function PhotoFilmSimulation({ contrast = 'low', prefetch, countOnHover, + recipe, }: { simulation: FilmSimulation countOnHover?: number + recipe?: FujifilmRecipe } & EntityLinkExternalProps) { const { small, medium, large } = labelForFilmSimulation(simulation); + const [shouldShowRecipe, setShouldShowRecipe] = useState(false); + return ( - } - title={`Film Simulation: ${large}`} - type={type} - badged={badged} - contrast={contrast} - prefetch={prefetch} - hoverEntity={countOnHover} - iconWide - /> +
    +
    + } + title={`Film Simulation: ${large}`} + type={type} + badged={badged} + contrast={contrast} + prefetch={prefetch} + hoverEntity={countOnHover} + iconWide + /> + {recipe && + + + } +
    + {recipe && shouldShowRecipe && + } +
    ); } From 66ccc5cf03d4b2de0535a1b4177eac195519ab0e Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Thu, 20 Feb 2025 09:13:09 -0600 Subject: [PATCH 08/38] Create temp recipe page --- app/admin/recipe/page.tsx | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 app/admin/recipe/page.tsx diff --git a/app/admin/recipe/page.tsx b/app/admin/recipe/page.tsx new file mode 100644 index 00000000..5a2a72d5 --- /dev/null +++ b/app/admin/recipe/page.tsx @@ -0,0 +1,22 @@ +import SiteGrid from '@/components/SiteGrid'; +import { getPhotos } from '@/photo/db/query'; +import PhotoRecipe from '@/photo/PhotoRecipe'; +import clsx from 'clsx/lite'; + +export default async function AdminRecipePage() { + const photos = await getPhotos({ hidden: 'only' }); + const { fujifilmRecipe } = photos[0]; + return ( + + {fujifilmRecipe && + + } +
    } + /> + ); +} + From 486c6dc1aebb1a32f4275950bd5da71583ff4f76 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Thu, 20 Feb 2025 22:18:40 -0600 Subject: [PATCH 09/38] Update recipe card design, add temp debug path --- app/admin/recipe/page.tsx | 12 ++- src/photo/PhotoRecipe.tsx | 143 +++++++++++++++++++++---- src/platforms/fujifilm/recipe.ts | 120 ++++++++++----------- src/simulation/PhotoFilmSimulation.tsx | 9 +- 4 files changed, 196 insertions(+), 88 deletions(-) diff --git a/app/admin/recipe/page.tsx b/app/admin/recipe/page.tsx index 5a2a72d5..47889c51 100644 --- a/app/admin/recipe/page.tsx +++ b/app/admin/recipe/page.tsx @@ -5,18 +5,20 @@ import clsx from 'clsx/lite'; export default async function AdminRecipePage() { const photos = await getPhotos({ hidden: 'only' }); - const { fujifilmRecipe } = photos[0]; + const { fujifilmRecipe, filmSimulation } = photos[0]; return ( - {fujifilmRecipe && - + {(fujifilmRecipe && filmSimulation) && + }
    } /> ); } - diff --git a/src/photo/PhotoRecipe.tsx b/src/photo/PhotoRecipe.tsx index b8365de1..485e09e2 100644 --- a/src/photo/PhotoRecipe.tsx +++ b/src/photo/PhotoRecipe.tsx @@ -1,25 +1,128 @@ import { FujifilmRecipe } from '@/platforms/fujifilm/recipe'; +import { FilmSimulation } from '@/simulation'; +import PhotoFilmSimulation from '@/simulation/PhotoFilmSimulation'; import clsx from 'clsx/lite'; -const addSign = (value: number) => value < 0 ? value : `+${value}`; +const addSign = (value = 0) => value < 0 ? value : `+${value}`; -export default function PhotoRecipe({ recipe: { - dynamicRange, - highlight, - shadow, - color, - highISONoiseReduction, - noiseReductionLegacy, - sharpness, - clarity, - grainEffect, - colorChromeEffect, - colorChromeFXBlue, - whiteBalance, - bwAdjustment, - bwMagentaGreen, -} }: { recipe: FujifilmRecipe }) { - return
    +export default function PhotoRecipe({ + recipe: { + dynamicRange, + whiteBalance, + highISONoiseReduction, + noiseReductionBasic, + highlight, + shadow, + color, + sharpness, + clarity, + colorChromeEffect, + colorChromeFXBlue, + grainEffect, + bwAdjustment, + bwMagentaGreen, + }, + simulation, +}: { + recipe: FujifilmRecipe + simulation: FilmSimulation +}) { + const whiteBalanceFormatted = (whiteBalance?.type ?? 'auto') + .replaceAll('auto', ' ') + .replaceAll('-', ' '); + + const hasCustomizedWhiteBalance = + Boolean(whiteBalance?.red) || + Boolean(whiteBalance?.blue); + + const hasBWAdjustments = + Boolean(bwAdjustment) || + Boolean(bwMagentaGreen); + + const renderDataSquare = (label: string, value: string | number = '0') => ( +
    +
    {typeof value === 'number' ? addSign(value) : value}
    +
    {label}
    +
    + ); + + return
    +
    +
    + +
    + DR + {dynamicRange ?? 100} +
    +
    +
    +
    + {whiteBalanceFormatted.length <= 8 && 'AWB: '} + {whiteBalanceFormatted} + {hasCustomizedWhiteBalance && <> + {' '} + {'('} + R{addSign(whiteBalance?.red ?? 0)} + / + B{addSign(whiteBalance?.blue ?? 0)} + {')'} + } +
    +
    + {renderDataSquare('Highlight', highlight)} + {renderDataSquare('Shadow', shadow)} +
    +
    + {/* TODO: Confirm color vs saturation label */} + {renderDataSquare('Color', color)} + {renderDataSquare('Sharp', sharpness)} + {renderDataSquare('Clarity', clarity)} +
    +
    + {renderDataSquare('Chrome', colorChromeEffect)} + {renderDataSquare('FX Blue', colorChromeFXBlue)} +
    +
    + {highISONoiseReduction !== undefined + ? <> + High ISO NR: + {addSign(highISONoiseReduction)} + + : <> + Noise Reduction: + {noiseReductionBasic} + + } +
    + {grainEffect && +
    + Grain: + {' '} + {grainEffect.roughness} + {' / '} + {grainEffect.size} +
    } + {hasBWAdjustments && +
    + BW Adjustment: + {' '} + {addSign(bwAdjustment)} + {' '} + MG: + {addSign(bwMagentaGreen)} +
    } +
    +
    High ISO NR
    {addSign(highISONoiseReduction)}
    } - {noiseReductionLegacy !== undefined && <> + {noiseReductionBasic !== undefined && <>
    NR
    -
    {noiseReductionLegacy}
    +
    {noiseReductionBasic}
    } {sharpness !== undefined && <>
    Sharpness
    diff --git a/src/platforms/fujifilm/recipe.ts b/src/platforms/fujifilm/recipe.ts index 1e5ed518..527eb0e5 100644 --- a/src/platforms/fujifilm/recipe.ts +++ b/src/platforms/fujifilm/recipe.ts @@ -1,19 +1,19 @@ import { parseFujifilmMakerNote } from '.'; const TAG_ID_DEVELOPMENT_DYNAMIC_RANGE = 0x1403; +const TAG_ID_WHITE_BALANCE = 0x1002; +const TAG_ID_WHITE_BALANCE_FINE_TUNE = 0x100a; +const TAG_ID_NOISE_REDUCTION = 0x100e; +const TAG_ID_NOISE_REDUCTION_BASIC = 0x100b; 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_BASIC = 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_GRAIN_EFFECT_ROUGHNESS = 0x1047; +const TAG_ID_GRAIN_EFFECT_SIZE = 0x104c; const TAG_ID_BW_ADJUSTMENT = 0x1049; const TAG_ID_BW_MAGENTA_GREEN = 0x104b; @@ -21,39 +21,39 @@ type WeakStrong = 'off' | 'weak' | 'strong'; export type FujifilmRecipe = Partial<{ dynamicRange: number - highlight: number - shadow: number - color: number - highISONoiseReduction: number - noiseReductionLegacy: string - sharpness: number - clarity: number - grainEffect: { - roughness: WeakStrong - size: 'off' | 'small' | 'large' - } - colorChromeEffect: WeakStrong - colorChromeFXBlue: WeakStrong whiteBalance: { type: string red: number blue: number } + highISONoiseReduction: number + noiseReductionBasic: string + highlight: number + shadow: number + color: number + sharpness: number + clarity: number + colorChromeEffect: WeakStrong + colorChromeFXBlue: WeakStrong + grainEffect: { + roughness: WeakStrong + size: 'off' | 'small' | 'large' + } 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; +const DEFAULT_GRAIN_EFFECT = { + roughness: 'off', + size: 'off', +} as const; + export const processTone = (value: number) => value === 0 ? 0 : -(value / 16); @@ -165,42 +165,6 @@ export const getFujifilmRecipeFromMakerNote = ( case TAG_ID_DEVELOPMENT_DYNAMIC_RANGE: 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_BASIC: - 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; @@ -214,6 +178,42 @@ export const getFujifilmRecipeFromMakerNote = ( recipe.whiteBalance.red = processWhiteBalanceComponent(numbers[0]); recipe.whiteBalance.blue = processWhiteBalanceComponent(numbers[1]); break; + case TAG_ID_NOISE_REDUCTION: + recipe.highISONoiseReduction = processNoiseReduction(numbers[0]); + break; + case TAG_ID_NOISE_REDUCTION_BASIC: + recipe.noiseReductionBasic = + processNoiseReductionLegacy(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_SHARPNESS: + recipe.sharpness = processSharpness(numbers[0]); + break; + case TAG_ID_CLARITY: + recipe.clarity = processClarity(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_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_BW_ADJUSTMENT: recipe.bwAdjustment = numbers[0]; break; diff --git a/src/simulation/PhotoFilmSimulation.tsx b/src/simulation/PhotoFilmSimulation.tsx index 7c3bda3a..4248e519 100644 --- a/src/simulation/PhotoFilmSimulation.tsx +++ b/src/simulation/PhotoFilmSimulation.tsx @@ -9,10 +9,11 @@ import EntityLink, { EntityLinkExternalProps, } from '@/components/primitives/EntityLink'; import { LuChevronsUpDown } from 'react-icons/lu'; -import clsx from 'clsx'; +import clsx from 'clsx/lite'; import { useState } from 'react'; import PhotoRecipe from '@/photo/PhotoRecipe'; import Tooltip from '@/components/Tooltip'; + export default function PhotoFilmSimulation({ simulation, type = 'icon-last', @@ -21,17 +22,19 @@ export default function PhotoFilmSimulation({ prefetch, countOnHover, recipe, + className, }: { simulation: FilmSimulation countOnHover?: number recipe?: FujifilmRecipe + className?: string } & EntityLinkExternalProps) { const { small, medium, large } = labelForFilmSimulation(simulation); const [shouldShowRecipe, setShouldShowRecipe] = useState(false); return ( -
    +
    }
    {recipe && shouldShowRecipe && - } + }
    ); } From 55afe9e09aae4a820d1cf1413728e3979cce081c Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Thu, 20 Feb 2025 22:19:07 -0600 Subject: [PATCH 10/38] Generalize makernote number parsing --- src/platforms/fujifilm/index.ts | 73 +++++++++++++++++++-------------- 1 file changed, 42 insertions(+), 31 deletions(-) diff --git a/src/platforms/fujifilm/index.ts b/src/platforms/fujifilm/index.ts index 30a17087..133e3b1e 100644 --- a/src/platforms/fujifilm/index.ts +++ b/src/platforms/fujifilm/index.ts @@ -6,64 +6,75 @@ import type { ExifData } from 'ts-exif-parser'; export const MAKE_FUJIFILM = 'FUJIFILM'; -const BYTE_INDEX_TAG_COUNT = 12; -const BYTE_INDEX_FIRST_TAG = 14; -const BYTES_PER_TAG = 12; +// Makernote Offsets +const BYTE_OFFSET_TAG_COUNT = 12; +const BYTE_OFFSET_FIRST_TAG = 14; + +// Tag Offsets const BYTE_OFFSET_TAG_TYPE = 2; const BYTE_OFFSET_TAG_SIZE = 4; const BYTE_OFFSET_TAG_VALUE = 8; +// Tag Sizes +const BYTES_PER_TAG = 12; +const BYTES_PER_TAG_VALUE = 4; + export const isExifForFujifilm = (data: ExifData) => data.tags?.Make === MAKE_FUJIFILM; export const parseFujifilmMakerNote = ( bytes: Buffer, - valueForTagNumbers: (tagId: number, numbers: number[]) => void, + sendTagNumbers: (tagId: number, numbers: number[]) => void, ) => { - const tagCount = bytes.readUint16LE(BYTE_INDEX_TAG_COUNT); + const tagCount = bytes.readUint16LE(BYTE_OFFSET_TAG_COUNT); + for (let i = 0; i < tagCount; i++) { - const index = BYTE_INDEX_FIRST_TAG + i * BYTES_PER_TAG; + const index = BYTE_OFFSET_FIRST_TAG + i * BYTES_PER_TAG; + 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); + + const sendNumbersForDataType = ( + calculateNumberForOffset: (offset: number) => number, + sizeInBytes: number, + ) => { + let values: number[] = []; + if (tagValueSize * sizeInBytes <= BYTES_PER_TAG_VALUE) { + // Retrieve values if they fit in tag block + values = Array.from({ length: tagValueSize }, (_, i) => + calculateNumberForOffset( + index + BYTE_OFFSET_TAG_VALUE + i * sizeInBytes, + ), + ); + } else { + // Retrieve outside values if they don't fit in tag block + const offset = bytes.readUint16LE(index + BYTE_OFFSET_TAG_VALUE); + values = []; + for (let i = 0; i < tagValueSize; i++) { + values.push(calculateNumberForOffset(offset + i * sizeInBytes)); + } + } + sendTagNumbers(tagId, values); + }; + switch (tagType) { // Int8 (UInt8 read as Int8) case 1: - valueForTagNumbers( - tagId, - [bytes.readInt8(index + BYTE_OFFSET_TAG_VALUE)], - ); + sendNumbersForDataType(offset => bytes.readInt8(offset), 1); break; // UInt16 case 3: - valueForTagNumbers( - tagId, - [bytes.readUInt16LE(index + BYTE_OFFSET_TAG_VALUE)], - ); + sendNumbersForDataType(offset => bytes.readUInt16LE(offset), 2); break; // UInt32 case 4: - valueForTagNumbers( - tagId, - [bytes.readUInt32LE(index + BYTE_OFFSET_TAG_VALUE)], - ); + sendNumbersForDataType(offset => bytes.readUInt32LE(offset), 4); 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); - } + sendNumbersForDataType(offset => bytes.readInt32LE(offset), 4); break; } } From 8c50496b742fcf80a8446706b29e2e04e1ec023e Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Thu, 20 Feb 2025 23:28:32 -0600 Subject: [PATCH 11/38] Address toast error --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- src/photo/form/PhotoForm.tsx | 14 ++------------ src/platforms/fujifilm/index.ts | 2 +- 4 files changed, 9 insertions(+), 19 deletions(-) diff --git a/package.json b/package.json index 03b5a316..04620d50 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "react-icons": "^5.5.0", "sanitize-html": "^2.14.0", "sharp": "^0.33.5", - "sonner": "^2.0.0", + "sonner": "^2.0.1", "swr": "^2.3.2", "ts-exif-parser": "^0.2.2", "use-debounce": "^10.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 18d3eb15..38df3f62 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -96,8 +96,8 @@ importers: specifier: ^0.33.5 version: 0.33.5 sonner: - specifier: ^2.0.0 - version: 2.0.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + specifier: ^2.0.1 + version: 2.0.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0) swr: specifier: ^2.3.2 version: 2.3.2(react@19.0.0) @@ -3859,8 +3859,8 @@ packages: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} - sonner@2.0.0: - resolution: {integrity: sha512-3WeSl3WrEdhmdiTniT8JsCiVRVDOdn7k+4MG2drqzSMOeqrExm03HIwDBPKuq52JBqL7wRLG17QBIiSleUbljw==} + sonner@2.0.1: + resolution: {integrity: sha512-FRBphaehZ5tLdLcQ8g2WOIRE+Y7BCfWi5Zyd8bCvBjiW8TxxAyoWZIxS661Yz6TGPqFQ4VLzOF89WEYhfynSFQ==} peerDependencies: react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc @@ -8805,7 +8805,7 @@ snapshots: slash@3.0.0: {} - sonner@2.0.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0): + sonner@2.0.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: react: 19.0.0 react-dom: 19.0.0(react@19.0.0) diff --git a/src/photo/form/PhotoForm.tsx b/src/photo/form/PhotoForm.tsx index d79875a7..cd6e7e0e 100644 --- a/src/photo/form/PhotoForm.tsx +++ b/src/photo/form/PhotoForm.tsx @@ -101,19 +101,9 @@ export default function PhotoForm({ if (changedKeys.length > 0) { const fields = convertFormKeysToLabels(changedKeys); - // Delay toasts to avoid render sync issue - const timeout = setTimeout( - () => toastSuccess(`Updated EXIF fields: ${fields.join(', ')}`, 8000), - 100, - ); - return () => clearTimeout(timeout); + toastSuccess(`Updated EXIF fields: ${fields.join(', ')}`, 8000); } else { - // Delay toasts to avoid render sync issue - const timeout = setTimeout( - () => toastWarning('No new EXIF data found'), - 100, - ); - return () => clearTimeout(timeout); + toastWarning('No new EXIF data found'); } } }, [updatedExifData]); diff --git a/src/platforms/fujifilm/index.ts b/src/platforms/fujifilm/index.ts index 133e3b1e..aab37fa6 100644 --- a/src/platforms/fujifilm/index.ts +++ b/src/platforms/fujifilm/index.ts @@ -60,7 +60,7 @@ export const parseFujifilmMakerNote = ( }; switch (tagType) { - // Int8 (UInt8 read as Int8) + // Int8 (UInt8 read as Int8 according to spec) case 1: sendNumbersForDataType(offset => bytes.readInt8(offset), 1); break; From 726b24f07b5f6502b8ad9f918243279fc5d3b3a1 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Thu, 20 Feb 2025 23:37:30 -0600 Subject: [PATCH 12/38] Refine initial receipt layout --- src/photo/PhotoRecipe.tsx | 96 ++++++--------------------------------- 1 file changed, 13 insertions(+), 83 deletions(-) diff --git a/src/photo/PhotoRecipe.tsx b/src/photo/PhotoRecipe.tsx index 485e09e2..98f4b366 100644 --- a/src/photo/PhotoRecipe.tsx +++ b/src/photo/PhotoRecipe.tsx @@ -42,29 +42,31 @@ export default function PhotoRecipe({ const renderDataSquare = (label: string, value: string | number = '0') => (
    {typeof value === 'number' ? addSign(value) : value}
    -
    {label}
    +
    + {label} +
    ); return
    -
    +
    DR {dynamicRange ?? 100}
    {whiteBalanceFormatted.length <= 8 && 'AWB: '} @@ -72,9 +74,9 @@ export default function PhotoRecipe({ {hasCustomizedWhiteBalance && <> {' '} {'('} - R{addSign(whiteBalance?.red ?? 0)} - / - B{addSign(whiteBalance?.blue ?? 0)} + R {addSign(whiteBalance?.red ?? 0)} + / + B {addSign(whiteBalance?.blue ?? 0)} {')'} }
    @@ -114,83 +116,11 @@ export default function PhotoRecipe({
    } {hasBWAdjustments &&
    - BW Adjustment: - {' '} - {addSign(bwAdjustment)} - {' '} - MG: - {addSign(bwMagentaGreen)} + BW Adjustment: {addSign(bwAdjustment)} + {' / '} + MG: {addSign(bwMagentaGreen)}
    }
    -
    - {dynamicRange !== undefined && <> -
    DR
    -
    {dynamicRange}
    - } - {highlight !== undefined && <> -
    Highlight
    -
    {addSign(highlight)}
    - } - {shadow !== undefined && <> -
    Shadow
    -
    {addSign(shadow)}
    - } - {color !== undefined && <> -
    Color
    -
    {addSign(color)}
    - } - {highISONoiseReduction !== undefined && <> -
    High ISO NR
    -
    {addSign(highISONoiseReduction)}
    - } - {noiseReductionBasic !== undefined && <> -
    NR
    -
    {noiseReductionBasic}
    - } - {sharpness !== undefined && <> -
    Sharpness
    -
    {addSign(sharpness)}
    - } - {clarity !== undefined && <> -
    Clarity
    -
    {addSign(clarity)}
    - } - {grainEffect !== undefined && <> -
    Grain
    -
    {grainEffect.roughness} / {grainEffect.size}
    - } - {colorChromeEffect !== undefined && <> -
    Chrome
    -
    {colorChromeEffect}
    - } - {colorChromeFXBlue !== undefined && <> -
    Chrome FX Blue
    -
    {colorChromeFXBlue}
    - } - {whiteBalance !== undefined && <> -
    White Balance
    -
    -
    {whiteBalance.type}
    -
    - {addSign(whiteBalance.red)} / {addSign(whiteBalance.blue)} -
    -
    - } - {bwAdjustment !== undefined && <> -
    BW
    -
    {addSign(bwAdjustment)}
    - } - {bwMagentaGreen !== undefined && <> -
    BW MG
    -
    {addSign(bwMagentaGreen)}
    - } -
    ; } From 381dd43263842d4c94333bc3445cc7abc24de6f7 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Fri, 21 Feb 2025 00:18:26 -0600 Subject: [PATCH 13/38] Create debug recipe photo overlay --- app/admin/recipe/page.tsx | 28 +++---- src/photo/PhotoRecipeFrost.tsx | 132 +++++++++++++++++++++++++++++++ src/photo/PhotoRecipeOverlay.tsx | 57 +++++++++++++ 3 files changed, 202 insertions(+), 15 deletions(-) create mode 100644 src/photo/PhotoRecipeFrost.tsx create mode 100644 src/photo/PhotoRecipeOverlay.tsx diff --git a/app/admin/recipe/page.tsx b/app/admin/recipe/page.tsx index 47889c51..cf1178a1 100644 --- a/app/admin/recipe/page.tsx +++ b/app/admin/recipe/page.tsx @@ -1,24 +1,22 @@ import SiteGrid from '@/components/SiteGrid'; import { getPhotos } from '@/photo/db/query'; -import PhotoRecipe from '@/photo/PhotoRecipe'; -import clsx from 'clsx/lite'; +import PhotoRecipeOverlay from '@/photo/PhotoRecipeOverlay'; export default async function AdminRecipePage() { - const photos = await getPhotos({ hidden: 'only' }); - const { fujifilmRecipe, filmSimulation } = photos[0]; + const photos = await getPhotos({ limit: 1}); + const photosHidden = await getPhotos({ hidden: 'only' }); + const { fujifilmRecipe, filmSimulation } = photosHidden[0]; return ( - {(fujifilmRecipe && filmSimulation) && - - } -
    } + contentMain={photos[0] && fujifilmRecipe && filmSimulation + ? + :
    + Can't find photo/recipe +
    } /> ); } diff --git a/src/photo/PhotoRecipeFrost.tsx b/src/photo/PhotoRecipeFrost.tsx new file mode 100644 index 00000000..de059b09 --- /dev/null +++ b/src/photo/PhotoRecipeFrost.tsx @@ -0,0 +1,132 @@ +import { FujifilmRecipe } from '@/platforms/fujifilm/recipe'; +import { FilmSimulation } from '@/simulation'; +import PhotoFilmSimulation from '@/simulation/PhotoFilmSimulation'; +import clsx from 'clsx/lite'; + +const addSign = (value = 0) => value < 0 ? value : `+${value}`; + +export default function PhotoRecipe({ + recipe: { + dynamicRange, + whiteBalance, + highISONoiseReduction, + noiseReductionBasic, + highlight, + shadow, + color, + sharpness, + clarity, + colorChromeEffect, + colorChromeFXBlue, + grainEffect, + bwAdjustment, + bwMagentaGreen, + }, + simulation, +}: { + recipe: FujifilmRecipe + simulation: FilmSimulation +}) { + const whiteBalanceFormatted = (whiteBalance?.type ?? 'auto') + .replaceAll('auto', ' ') + .replaceAll('-', ' '); + + const hasCustomizedWhiteBalance = + Boolean(whiteBalance?.red) || + Boolean(whiteBalance?.blue); + + const hasBWAdjustments = + Boolean(bwAdjustment) || + Boolean(bwMagentaGreen); + + const renderDataSquare = (label: string, value: string | number = '0') => ( +
    +
    {typeof value === 'number' ? addSign(value) : value}
    +
    + {label} +
    +
    + ); + + return
    +
    +
    + +
    + DR + {dynamicRange ?? 100} +
    +
    +
    +
    + {whiteBalanceFormatted.length <= 8 && 'AWB: '} + {whiteBalanceFormatted} + {hasCustomizedWhiteBalance && <> + {' '} + {'('} + R {addSign(whiteBalance?.red ?? 0)} + / + B {addSign(whiteBalance?.blue ?? 0)} + {')'} + } +
    +
    + {renderDataSquare('Highlight', highlight)} + {renderDataSquare('Shadow', shadow)} +
    +
    + {/* TODO: Confirm color vs saturation label */} + {renderDataSquare('Color', color)} + {renderDataSquare('Sharp', sharpness)} + {renderDataSquare('Clarity', clarity)} +
    +
    + {renderDataSquare('Chrome', colorChromeEffect)} + {renderDataSquare('FX Blue', colorChromeFXBlue)} +
    +
    + {highISONoiseReduction !== undefined + ? <> + High ISO NR: + {addSign(highISONoiseReduction)} + + : <> + Noise Reduction: + {noiseReductionBasic} + + } +
    + {grainEffect && +
    + Grain: + {' '} + {grainEffect.roughness} + {' / '} + {grainEffect.size} +
    } + {hasBWAdjustments && +
    + BW Adjustment: {addSign(bwAdjustment)} + {' / '} + MG: {addSign(bwMagentaGreen)} +
    } +
    +
    +
    ; +} diff --git a/src/photo/PhotoRecipeOverlay.tsx b/src/photo/PhotoRecipeOverlay.tsx new file mode 100644 index 00000000..281f6d2b --- /dev/null +++ b/src/photo/PhotoRecipeOverlay.tsx @@ -0,0 +1,57 @@ +'use client'; + +import { FujifilmRecipe } from '@/platforms/fujifilm/recipe'; +import { FilmSimulation } from '@/simulation'; +import clsx from 'clsx/lite'; +import ImageLarge from '@/components/image/ImageLarge'; +import PhotoRecipeFrost from './PhotoRecipeFrost'; +import FieldSetWithStatus from '@/components/FieldSetWithStatus'; +import { useState } from 'react'; +import PhotoRecipe from './PhotoRecipe'; +export default function PhotoRecipeOverlay({ + backgroundImageUrl, + recipe, + simulation, +}: { + backgroundImageUrl: string + recipe: FujifilmRecipe + simulation: FilmSimulation +}) { + const [isFrosted, setIsFrosted] = useState(true); + + return ( +
    +
    + setIsFrosted(!isFrosted)} + /> +
    +
    + +
    + {isFrosted + ? : } +
    +
    +
    + ); +} From 338426114eedbce6082a4d549dba7ce348466d83 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Fri, 21 Feb 2025 17:24:19 -0600 Subject: [PATCH 14/38] Sketch on recipe visualization --- app/admin/recipe/[photoId]/page.tsx | 29 ++++ app/admin/recipe/page.tsx | 19 +-- src/components/SiteGrid.tsx | 6 +- src/photo/PhotoRecipe.tsx | 10 +- ...cipeFrost.tsx => PhotoRecipeFrostDark.tsx} | 2 +- src/photo/PhotoRecipeFrostLight.tsx | 150 ++++++++++++++++++ src/photo/PhotoRecipeOverlay.tsx | 4 +- 7 files changed, 193 insertions(+), 27 deletions(-) create mode 100644 app/admin/recipe/[photoId]/page.tsx rename src/photo/{PhotoRecipeFrost.tsx => PhotoRecipeFrostDark.tsx} (98%) create mode 100644 src/photo/PhotoRecipeFrostLight.tsx diff --git a/app/admin/recipe/[photoId]/page.tsx b/app/admin/recipe/[photoId]/page.tsx new file mode 100644 index 00000000..20841206 --- /dev/null +++ b/app/admin/recipe/[photoId]/page.tsx @@ -0,0 +1,29 @@ +import SiteGrid from '@/components/SiteGrid'; +import { getPhoto, getPhotos } from '@/photo/db/query'; +import PhotoRecipeOverlay from '@/photo/PhotoRecipeOverlay'; + +export default async function AdminRecipePage({ + params, +}: { + params: Promise<{ photoId: string }> +}) { + const { photoId } = await params; + const photo = await getPhoto(photoId); + const photosHidden = await getPhotos({ hidden: 'only' }); + const { filmSimulation } = photo!; + const { fujifilmRecipe } = photosHidden[0]; + + return ( + + :
    + Can't find photo/recipe +
    } + /> + ); +} diff --git a/app/admin/recipe/page.tsx b/app/admin/recipe/page.tsx index cf1178a1..8401a97c 100644 --- a/app/admin/recipe/page.tsx +++ b/app/admin/recipe/page.tsx @@ -1,22 +1,7 @@ -import SiteGrid from '@/components/SiteGrid'; import { getPhotos } from '@/photo/db/query'; -import PhotoRecipeOverlay from '@/photo/PhotoRecipeOverlay'; +import { redirect } from 'next/navigation'; export default async function AdminRecipePage() { const photos = await getPhotos({ limit: 1}); - const photosHidden = await getPhotos({ hidden: 'only' }); - const { fujifilmRecipe, filmSimulation } = photosHidden[0]; - return ( - - :
    - Can't find photo/recipe -
    } - /> - ); + redirect(`/admin/recipe/${photos[0].id}`); } diff --git a/src/components/SiteGrid.tsx b/src/components/SiteGrid.tsx index 59bbd0a1..842caccd 100644 --- a/src/components/SiteGrid.tsx +++ b/src/components/SiteGrid.tsx @@ -1,5 +1,5 @@ import { clsx } from 'clsx/lite'; -import { JSX, RefObject } from 'react'; +import { HTMLAttributes, JSX, RefObject } from 'react'; /* MAX WIDTHS @@ -18,6 +18,7 @@ export default function SiteGrid({ contentSide, sideFirstOnMobile, sideHiddenOnMobile, + ...props }: { containerRef?: RefObject className?: string @@ -25,9 +26,10 @@ export default function SiteGrid({ contentSide?: JSX.Element sideFirstOnMobile?: boolean sideHiddenOnMobile?: boolean -}) { +} & HTMLAttributes) { return (
    -
    +
    DR @@ -66,7 +66,7 @@ export default function PhotoRecipe({
    {whiteBalanceFormatted.length <= 8 && 'AWB: '} @@ -80,17 +80,17 @@ export default function PhotoRecipe({ {')'} }
    -
    +
    {renderDataSquare('Highlight', highlight)} {renderDataSquare('Shadow', shadow)}
    -
    +
    {/* TODO: Confirm color vs saturation label */} {renderDataSquare('Color', color)} {renderDataSquare('Sharp', sharpness)} {renderDataSquare('Clarity', clarity)}
    -
    +
    {renderDataSquare('Chrome', colorChromeEffect)} {renderDataSquare('FX Blue', colorChromeFXBlue)}
    diff --git a/src/photo/PhotoRecipeFrost.tsx b/src/photo/PhotoRecipeFrostDark.tsx similarity index 98% rename from src/photo/PhotoRecipeFrost.tsx rename to src/photo/PhotoRecipeFrostDark.tsx index de059b09..d0b9bb20 100644 --- a/src/photo/PhotoRecipeFrost.tsx +++ b/src/photo/PhotoRecipeFrostDark.tsx @@ -5,7 +5,7 @@ import clsx from 'clsx/lite'; const addSign = (value = 0) => value < 0 ? value : `+${value}`; -export default function PhotoRecipe({ +export default function PhotoRecipeFrostDark({ recipe: { dynamicRange, whiteBalance, diff --git a/src/photo/PhotoRecipeFrostLight.tsx b/src/photo/PhotoRecipeFrostLight.tsx new file mode 100644 index 00000000..d65e5d88 --- /dev/null +++ b/src/photo/PhotoRecipeFrostLight.tsx @@ -0,0 +1,150 @@ +import { FujifilmRecipe } from '@/platforms/fujifilm/recipe'; +import { FilmSimulation } from '@/simulation'; +import PhotoFilmSimulation from '@/simulation/PhotoFilmSimulation'; +import clsx from 'clsx/lite'; + +const addSign = (value = 0) => value < 0 ? value : `+${value}`; + +const getRandomInt = () => { + const randomInt = Math.floor(Math.random() * 4) + 1; + return Math.random() >= 0.5 ? randomInt : -randomInt; +}; + +const random = { + highlight: getRandomInt(), + shadow: getRandomInt(), + color: getRandomInt(), + sharpness: getRandomInt(), + clarity: getRandomInt(), + colorChromeEffect: getRandomInt(), + colorChromeFXBlue: getRandomInt(), +}; + +export default function PhotoRecipeFrostLight({ + recipe: { + dynamicRange, + whiteBalance, + highISONoiseReduction, + noiseReductionBasic, + highlight, + shadow, + color, + sharpness, + clarity, + colorChromeEffect, + colorChromeFXBlue, + grainEffect, + bwAdjustment, + bwMagentaGreen, + }, + simulation, +}: { + recipe: FujifilmRecipe + simulation: FilmSimulation +}) { + const whiteBalanceFormatted = (whiteBalance?.type ?? 'auto') + .replaceAll('auto', ' ') + .replaceAll('-', ' '); + + const hasCustomizedWhiteBalance = + Boolean(whiteBalance?.red) || + Boolean(whiteBalance?.blue); + + const hasBWAdjustments = + Boolean(bwAdjustment) || + Boolean(bwMagentaGreen); + + const renderDataSquare = (label: string, value: string | number = '0') => ( +
    +
    {typeof value === 'number' ? addSign(value) : value}
    +
    + {label} +
    +
    + ); + + return
    +
    +
    + +
    + DR + {dynamicRange ?? 100} +
    +
    +
    +
    + {whiteBalanceFormatted.length <= 8 && 'AWB: '} + {whiteBalanceFormatted} + {hasCustomizedWhiteBalance && <> + {' '} + {'('} + R {addSign(whiteBalance?.red ?? 0)} + / + B {addSign(whiteBalance?.blue ?? 0)} + {')'} + } +
    +
    + {renderDataSquare('Highlight', highlight || random.highlight)} + {renderDataSquare('Shadow', shadow || random.shadow)} +
    +
    + {/* TODO: Confirm color vs saturation label */} + {renderDataSquare('Color', color || random.color)} + {renderDataSquare('Sharpness', sharpness || random.sharpness)} + {renderDataSquare('Clarity', clarity || random.clarity)} +
    +
    + {renderDataSquare('Color Chrome', colorChromeEffect)} + {renderDataSquare('FX Blue', colorChromeFXBlue)} +
    +
    + {highISONoiseReduction !== undefined + ? <> + High ISO NR: + {addSign(highISONoiseReduction)} + + : <> + Noise Reduction: + {noiseReductionBasic} + + } +
    + {grainEffect && +
    + Grain: + {grainEffect.roughness} + {' / '} + {grainEffect.size} +
    } + {hasBWAdjustments && +
    + BW Adjustment: + {addSign(bwAdjustment)} + {' / '} + MG: {addSign(bwMagentaGreen)} +
    } +
    +
    +
    ; +} diff --git a/src/photo/PhotoRecipeOverlay.tsx b/src/photo/PhotoRecipeOverlay.tsx index 281f6d2b..7cdca292 100644 --- a/src/photo/PhotoRecipeOverlay.tsx +++ b/src/photo/PhotoRecipeOverlay.tsx @@ -4,7 +4,7 @@ import { FujifilmRecipe } from '@/platforms/fujifilm/recipe'; import { FilmSimulation } from '@/simulation'; import clsx from 'clsx/lite'; import ImageLarge from '@/components/image/ImageLarge'; -import PhotoRecipeFrost from './PhotoRecipeFrost'; +import PhotoRecipeFrostLight from './PhotoRecipeFrostLight'; import FieldSetWithStatus from '@/components/FieldSetWithStatus'; import { useState } from 'react'; import PhotoRecipe from './PhotoRecipe'; @@ -43,7 +43,7 @@ export default function PhotoRecipeOverlay({ 'flex items-center justify-center', )}> {isFrosted - ? : Date: Sat, 22 Feb 2025 10:45:45 -0600 Subject: [PATCH 15/38] Visual pass on recipes --- app/admin/recipe/[photoId]/page.tsx | 2 + src/components/Badge.tsx | 14 +- src/components/primitives/EntityLink.tsx | 8 +- src/photo/PhotoRecipeFrostLight.tsx | 98 +++++++------- src/photo/PhotoRecipeFrostLightV2.tsx | 156 +++++++++++++++++++++++ src/photo/PhotoRecipeOverlay.tsx | 37 ++---- 6 files changed, 235 insertions(+), 80 deletions(-) create mode 100644 src/photo/PhotoRecipeFrostLightV2.tsx diff --git a/app/admin/recipe/[photoId]/page.tsx b/app/admin/recipe/[photoId]/page.tsx index 20841206..aaacdcfb 100644 --- a/app/admin/recipe/[photoId]/page.tsx +++ b/app/admin/recipe/[photoId]/page.tsx @@ -20,6 +20,8 @@ export default async function AdminRecipePage({ backgroundImageUrl={photo.url} recipe={fujifilmRecipe} simulation={filmSimulation} + exposure={photo.exposureCompensationFormatted ?? '+0ev'} + iso={photo.isoFormatted ?? 'ISO 0'} /> :
    Can't find photo/recipe diff --git a/src/components/Badge.tsx b/src/components/Badge.tsx index a4277ff4..b2cb49a6 100644 --- a/src/components/Badge.tsx +++ b/src/components/Badge.tsx @@ -5,7 +5,7 @@ export default function Badge({ className, type = 'large', dimContent, - highContrast, + contrast = 'low', uppercase, interactive, }: { @@ -13,7 +13,7 @@ export default function Badge({ className?: string type?: 'large' | 'small' | 'text-only' dimContent?: boolean - highContrast?: boolean + contrast?: 'low' | 'medium' | 'high' | 'frost' uppercase?: boolean interactive?: boolean }) { @@ -30,13 +30,15 @@ export default function Badge({ return clsx( 'px-[5px] h-[17px] md:h-[18px]', 'text-[0.7rem] font-medium rounded-[0.25rem]', - highContrast + contrast === 'high' ? 'text-invert bg-invert' - : 'text-medium bg-gray-300/30 dark:bg-gray-700/50', - interactive && (highContrast + : contrast === 'frost' + ? 'text-black bg-white/30' + : 'text-medium bg-gray-300/30 dark:bg-gray-700/50', + interactive && (contrast === 'high' ? 'hover:opacity-70' : 'hover:text-gray-900 dark:hover:text-gray-100'), - interactive && (highContrast + interactive && (contrast === 'high' ? 'active:opacity-90' : 'active:bg-gray-200 dark:active:bg-gray-700/60'), ); diff --git a/src/components/primitives/EntityLink.tsx b/src/components/primitives/EntityLink.tsx index a13f68d0..d0765640 100644 --- a/src/components/primitives/EntityLink.tsx +++ b/src/components/primitives/EntityLink.tsx @@ -1,6 +1,6 @@ 'use client'; -import { ReactNode } from 'react'; +import { ComponentProps, ReactNode } from 'react'; import LabeledIcon, { LabeledIconType } from './LabeledIcon'; import Badge from '../Badge'; import { clsx } from 'clsx/lite'; @@ -10,7 +10,7 @@ import Spinner from '../Spinner'; export interface EntityLinkExternalProps { type?: LabeledIconType badged?: boolean - contrast?: 'low' | 'medium' | 'high' + contrast?: ComponentProps['contrast'] prefetch?: boolean } @@ -48,6 +48,8 @@ export default function EntityLink({ return 'text-dim'; case 'high': return 'text-main'; + case 'frost': + return 'text-invert'; default: return 'text-medium'; } @@ -88,7 +90,7 @@ export default function EntityLink({ {badged ? value < 0 ? value : `+${value}`; @@ -38,18 +39,18 @@ export default function PhotoRecipeFrostLight({ bwMagentaGreen, }, simulation, + exposure, + iso, }: { recipe: FujifilmRecipe simulation: FilmSimulation + exposure: string + iso: string }) { const whiteBalanceFormatted = (whiteBalance?.type ?? 'auto') .replaceAll('auto', ' ') .replaceAll('-', ' '); - const hasCustomizedWhiteBalance = - Boolean(whiteBalance?.red) || - Boolean(whiteBalance?.blue); - const hasBWAdjustments = Boolean(bwAdjustment) || Boolean(bwMagentaGreen); @@ -75,34 +76,46 @@ export default function PhotoRecipeFrostLight({ 'rounded-lg shadow-2xl', 'bg-white/60 backdrop-blur-xl border border-white/30', 'space-y-3', - 'text-[13px] text-main', + 'text-[13px] text-black', 'saturate-200', )}>
    -
    - DR - {dynamicRange ?? 100} -
    +
    -
    -
    - {whiteBalanceFormatted.length <= 8 && 'AWB: '} - {whiteBalanceFormatted} - {hasCustomizedWhiteBalance && <> - {' '} - {'('} - R {addSign(whiteBalance?.red ?? 0)} - / - B {addSign(whiteBalance?.blue ?? 0)} - {')'} - } +
    +
    +
    + DR{dynamicRange ?? 100} +
    +
    + {iso} +
    +
    + {exposure} +
    +
    +
    + {renderDataSquare( + `R${addSign(whiteBalance?.red)} / B${addSign(whiteBalance?.blue)}`, + whiteBalanceFormatted, + )}
    {renderDataSquare('Highlight', highlight || random.highlight)} @@ -118,31 +131,24 @@ export default function PhotoRecipeFrostLight({ {renderDataSquare('Color Chrome', colorChromeEffect)} {renderDataSquare('FX Blue', colorChromeFXBlue)}
    -
    - {highISONoiseReduction !== undefined - ? <> - High ISO NR: - {addSign(highISONoiseReduction)} - - : <> - Noise Reduction: - {noiseReductionBasic} - - } -
    {grainEffect && -
    - Grain: - {grainEffect.roughness} - {' / '} - {grainEffect.size} +
    + {renderDataSquare( + highISONoiseReduction !== undefined + ? 'High ISO NR' + : 'Noise Reduction', + highISONoiseReduction ?? noiseReductionBasic, + )} + {renderDataSquare( + 'Grain', + // eslint-disable-next-line max-len + `${grainEffect.roughness} / ${grainEffect.size === 'large' ? 'LG' : grainEffect.size === 'small' ? 'SM' : 'OFF'}`, + )}
    } {hasBWAdjustments && -
    - BW Adjustment: - {addSign(bwAdjustment)} - {' / '} - MG: {addSign(bwMagentaGreen)} +
    + {renderDataSquare('BW Adjustment', bwAdjustment)} + {renderDataSquare('BW Magenta Green', bwMagentaGreen)}
    }
    diff --git a/src/photo/PhotoRecipeFrostLightV2.tsx b/src/photo/PhotoRecipeFrostLightV2.tsx new file mode 100644 index 00000000..5a0e006c --- /dev/null +++ b/src/photo/PhotoRecipeFrostLightV2.tsx @@ -0,0 +1,156 @@ +import { FujifilmRecipe } from '@/platforms/fujifilm/recipe'; +import { FilmSimulation } from '@/simulation'; +import PhotoFilmSimulation from '@/simulation/PhotoFilmSimulation'; +import clsx from 'clsx/lite'; +import { IoCloseCircle } from 'react-icons/io5'; + +const addSign = (value = 0) => value < 0 ? value : `+${value}`; + +const getRandomInt = () => { + const randomInt = Math.floor(Math.random() * 4) + 1; + return Math.random() >= 0.5 ? randomInt : -randomInt; +}; + +const random = { + highlight: getRandomInt(), + shadow: getRandomInt(), + color: getRandomInt(), + sharpness: getRandomInt(), + clarity: getRandomInt(), + colorChromeEffect: getRandomInt(), + colorChromeFXBlue: getRandomInt(), +}; + +export default function PhotoRecipeFrostLightV2({ + recipe: { + dynamicRange, + whiteBalance, + highISONoiseReduction, + noiseReductionBasic, + highlight, + shadow, + color, + sharpness, + clarity, + colorChromeEffect, + colorChromeFXBlue, + grainEffect, + bwAdjustment, + bwMagentaGreen, + }, + simulation, + exposure, + iso, +}: { + recipe: FujifilmRecipe + simulation: FilmSimulation + exposure: string + iso: string +}) { + const whiteBalanceFormatted = (whiteBalance?.type ?? 'auto') + .replaceAll('auto', ' ') + .replaceAll('-', ' '); + + const hasBWAdjustments = + Boolean(bwAdjustment) || + Boolean(bwMagentaGreen); + + const renderDataSquare = (label: string, value: string | number = '0') => ( +
    +
    {typeof value === 'number' ? addSign(value) : value}
    +
    + {label} +
    +
    + ); + + return
    +
    +
    + + +
    +
    +
    +
    + DR{dynamicRange ?? 100} +
    +
    + {iso} +
    +
    + {exposure} +
    +
    +
    + {renderDataSquare( + `R${addSign(whiteBalance?.red)} / B${addSign(whiteBalance?.blue)}`, + whiteBalanceFormatted, + )} +
    +
    + {renderDataSquare('Highlight', highlight || random.highlight)} + {renderDataSquare('Shadow', shadow || random.shadow)} +
    +
    + {/* TODO: Confirm color vs saturation label */} + {renderDataSquare('Color', color || random.color)} + {renderDataSquare('Sharpness', sharpness || random.sharpness)} + {renderDataSquare('Clarity', clarity || random.clarity)} +
    +
    + {renderDataSquare('Color Chrome', colorChromeEffect)} + {renderDataSquare('FX Blue', colorChromeFXBlue)} +
    + {grainEffect && +
    + {renderDataSquare( + highISONoiseReduction !== undefined + ? 'High ISO NR' + : 'Noise Reduction', + highISONoiseReduction ?? noiseReductionBasic, + )} + {renderDataSquare( + 'Grain', + // eslint-disable-next-line max-len + `${grainEffect.roughness} / ${grainEffect.size === 'large' ? 'LG' : grainEffect.size === 'small' ? 'SM' : 'OFF'}`, + )} +
    } + {hasBWAdjustments && +
    + {renderDataSquare('BW Adjustment', bwAdjustment)} + {renderDataSquare('BW Magenta Green', bwMagentaGreen)} +
    } +
    +
    +
    ; +} diff --git a/src/photo/PhotoRecipeOverlay.tsx b/src/photo/PhotoRecipeOverlay.tsx index 7cdca292..d8ca5cee 100644 --- a/src/photo/PhotoRecipeOverlay.tsx +++ b/src/photo/PhotoRecipeOverlay.tsx @@ -1,35 +1,24 @@ -'use client'; - import { FujifilmRecipe } from '@/platforms/fujifilm/recipe'; import { FilmSimulation } from '@/simulation'; import clsx from 'clsx/lite'; import ImageLarge from '@/components/image/ImageLarge'; -import PhotoRecipeFrostLight from './PhotoRecipeFrostLight'; -import FieldSetWithStatus from '@/components/FieldSetWithStatus'; -import { useState } from 'react'; -import PhotoRecipe from './PhotoRecipe'; +import PhotoRecipeFrostLightV2 from './PhotoRecipeFrostLightV2'; + export default function PhotoRecipeOverlay({ backgroundImageUrl, recipe, simulation, + exposure, + iso, }: { backgroundImageUrl: string recipe: FujifilmRecipe simulation: FilmSimulation + exposure: string + iso: string }) { - const [isFrosted, setIsFrosted] = useState(true); - return (
    -
    - setIsFrosted(!isFrosted)} - /> -
    @@ -42,14 +31,12 @@ export default function PhotoRecipeOverlay({ 'absolute inset-0', 'flex items-center justify-center', )}> - {isFrosted - ? : } +
    From dbb743468d6b9cf1d2c642e0eda2e53487bc58e4 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sat, 22 Feb 2025 11:37:34 -0600 Subject: [PATCH 16/38] Standardize on one recipe layout --- src/photo/PhotoRecipe.tsx | 144 ++++++++++++++---------- src/photo/PhotoRecipeFrostDark.tsx | 132 ---------------------- src/photo/PhotoRecipeFrostLight.tsx | 156 -------------------------- src/photo/PhotoRecipeFrostLightV2.tsx | 156 -------------------------- src/photo/PhotoRecipeOverlay.tsx | 4 +- 5 files changed, 89 insertions(+), 503 deletions(-) delete mode 100644 src/photo/PhotoRecipeFrostDark.tsx delete mode 100644 src/photo/PhotoRecipeFrostLight.tsx delete mode 100644 src/photo/PhotoRecipeFrostLightV2.tsx diff --git a/src/photo/PhotoRecipe.tsx b/src/photo/PhotoRecipe.tsx index cc14661d..5465cdac 100644 --- a/src/photo/PhotoRecipe.tsx +++ b/src/photo/PhotoRecipe.tsx @@ -2,9 +2,25 @@ import { FujifilmRecipe } from '@/platforms/fujifilm/recipe'; import { FilmSimulation } from '@/simulation'; import PhotoFilmSimulation from '@/simulation/PhotoFilmSimulation'; import clsx from 'clsx/lite'; +import { IoCloseCircle } from 'react-icons/io5'; const addSign = (value = 0) => value < 0 ? value : `+${value}`; +const getRandomInt = () => { + const randomInt = Math.floor(Math.random() * 4) + 1; + return Math.random() >= 0.5 ? randomInt : -randomInt; +}; + +const random = { + highlight: getRandomInt(), + shadow: getRandomInt(), + color: getRandomInt(), + sharpness: getRandomInt(), + clarity: getRandomInt(), + colorChromeEffect: getRandomInt(), + colorChromeFXBlue: getRandomInt(), +}; + export default function PhotoRecipe({ recipe: { dynamicRange, @@ -23,29 +39,31 @@ export default function PhotoRecipe({ bwMagentaGreen, }, simulation, + exposure, + iso, }: { recipe: FujifilmRecipe simulation: FilmSimulation + exposure: string + iso: string }) { const whiteBalanceFormatted = (whiteBalance?.type ?? 'auto') .replaceAll('auto', ' ') .replaceAll('-', ' '); - const hasCustomizedWhiteBalance = - Boolean(whiteBalance?.red) || - Boolean(whiteBalance?.blue); - const hasBWAdjustments = Boolean(bwAdjustment) || Boolean(bwMagentaGreen); const renderDataSquare = (label: string, value: string | number = '0') => (
    {typeof value === 'number' ? addSign(value) : value}
    -
    +
    {label}
    @@ -53,72 +71,84 @@ export default function PhotoRecipe({ return
    -
    - -
    - DR - {dynamicRange ?? 100} -
    +
    + +
    -
    -
    - {whiteBalanceFormatted.length <= 8 && 'AWB: '} - {whiteBalanceFormatted} - {hasCustomizedWhiteBalance && <> - {' '} - {'('} - R {addSign(whiteBalance?.red ?? 0)} - / - B {addSign(whiteBalance?.blue ?? 0)} - {')'} - } +
    +
    +
    + DR{dynamicRange ?? 100} +
    +
    + {iso} +
    +
    + {exposure} +
    - {renderDataSquare('Highlight', highlight)} - {renderDataSquare('Shadow', shadow)} + {renderDataSquare( + `R${addSign(whiteBalance?.red)} / B${addSign(whiteBalance?.blue)}`, + whiteBalanceFormatted, + )} +
    +
    + {renderDataSquare('Highlight', highlight || random.highlight)} + {renderDataSquare('Shadow', shadow || random.shadow)}
    {/* TODO: Confirm color vs saturation label */} - {renderDataSquare('Color', color)} - {renderDataSquare('Sharp', sharpness)} - {renderDataSquare('Clarity', clarity)} + {renderDataSquare('Color', color || random.color)} + {renderDataSquare('Sharpness', sharpness || random.sharpness)} + {renderDataSquare('Clarity', clarity || random.clarity)}
    - {renderDataSquare('Chrome', colorChromeEffect)} + {renderDataSquare('Color Chrome', colorChromeEffect)} {renderDataSquare('FX Blue', colorChromeFXBlue)}
    -
    - {highISONoiseReduction !== undefined - ? <> - High ISO NR: - {addSign(highISONoiseReduction)} - - : <> - Noise Reduction: - {noiseReductionBasic} - - } -
    {grainEffect && -
    - Grain: - {' '} - {grainEffect.roughness} - {' / '} - {grainEffect.size} +
    + {renderDataSquare( + highISONoiseReduction !== undefined + ? 'High ISO NR' + : 'Noise Reduction', + highISONoiseReduction ?? noiseReductionBasic, + )} + {renderDataSquare( + 'Grain', + // eslint-disable-next-line max-len + `${grainEffect.roughness} / ${grainEffect.size === 'large' ? 'LG' : grainEffect.size === 'small' ? 'SM' : 'OFF'}`, + )}
    } {hasBWAdjustments && -
    - BW Adjustment: {addSign(bwAdjustment)} - {' / '} - MG: {addSign(bwMagentaGreen)} +
    + {renderDataSquare('BW Adjustment', bwAdjustment)} + {renderDataSquare('BW Magenta Green', bwMagentaGreen)}
    }
    diff --git a/src/photo/PhotoRecipeFrostDark.tsx b/src/photo/PhotoRecipeFrostDark.tsx deleted file mode 100644 index d0b9bb20..00000000 --- a/src/photo/PhotoRecipeFrostDark.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { FujifilmRecipe } from '@/platforms/fujifilm/recipe'; -import { FilmSimulation } from '@/simulation'; -import PhotoFilmSimulation from '@/simulation/PhotoFilmSimulation'; -import clsx from 'clsx/lite'; - -const addSign = (value = 0) => value < 0 ? value : `+${value}`; - -export default function PhotoRecipeFrostDark({ - recipe: { - dynamicRange, - whiteBalance, - highISONoiseReduction, - noiseReductionBasic, - highlight, - shadow, - color, - sharpness, - clarity, - colorChromeEffect, - colorChromeFXBlue, - grainEffect, - bwAdjustment, - bwMagentaGreen, - }, - simulation, -}: { - recipe: FujifilmRecipe - simulation: FilmSimulation -}) { - const whiteBalanceFormatted = (whiteBalance?.type ?? 'auto') - .replaceAll('auto', ' ') - .replaceAll('-', ' '); - - const hasCustomizedWhiteBalance = - Boolean(whiteBalance?.red) || - Boolean(whiteBalance?.blue); - - const hasBWAdjustments = - Boolean(bwAdjustment) || - Boolean(bwMagentaGreen); - - const renderDataSquare = (label: string, value: string | number = '0') => ( -
    -
    {typeof value === 'number' ? addSign(value) : value}
    -
    - {label} -
    -
    - ); - - return
    -
    -
    - -
    - DR - {dynamicRange ?? 100} -
    -
    -
    -
    - {whiteBalanceFormatted.length <= 8 && 'AWB: '} - {whiteBalanceFormatted} - {hasCustomizedWhiteBalance && <> - {' '} - {'('} - R {addSign(whiteBalance?.red ?? 0)} - / - B {addSign(whiteBalance?.blue ?? 0)} - {')'} - } -
    -
    - {renderDataSquare('Highlight', highlight)} - {renderDataSquare('Shadow', shadow)} -
    -
    - {/* TODO: Confirm color vs saturation label */} - {renderDataSquare('Color', color)} - {renderDataSquare('Sharp', sharpness)} - {renderDataSquare('Clarity', clarity)} -
    -
    - {renderDataSquare('Chrome', colorChromeEffect)} - {renderDataSquare('FX Blue', colorChromeFXBlue)} -
    -
    - {highISONoiseReduction !== undefined - ? <> - High ISO NR: - {addSign(highISONoiseReduction)} - - : <> - Noise Reduction: - {noiseReductionBasic} - - } -
    - {grainEffect && -
    - Grain: - {' '} - {grainEffect.roughness} - {' / '} - {grainEffect.size} -
    } - {hasBWAdjustments && -
    - BW Adjustment: {addSign(bwAdjustment)} - {' / '} - MG: {addSign(bwMagentaGreen)} -
    } -
    -
    -
    ; -} diff --git a/src/photo/PhotoRecipeFrostLight.tsx b/src/photo/PhotoRecipeFrostLight.tsx deleted file mode 100644 index 2a023d36..00000000 --- a/src/photo/PhotoRecipeFrostLight.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import { FujifilmRecipe } from '@/platforms/fujifilm/recipe'; -import { FilmSimulation } from '@/simulation'; -import PhotoFilmSimulation from '@/simulation/PhotoFilmSimulation'; -import clsx from 'clsx/lite'; -import { IoCloseCircle } from 'react-icons/io5'; - -const addSign = (value = 0) => value < 0 ? value : `+${value}`; - -const getRandomInt = () => { - const randomInt = Math.floor(Math.random() * 4) + 1; - return Math.random() >= 0.5 ? randomInt : -randomInt; -}; - -const random = { - highlight: getRandomInt(), - shadow: getRandomInt(), - color: getRandomInt(), - sharpness: getRandomInt(), - clarity: getRandomInt(), - colorChromeEffect: getRandomInt(), - colorChromeFXBlue: getRandomInt(), -}; - -export default function PhotoRecipeFrostLight({ - recipe: { - dynamicRange, - whiteBalance, - highISONoiseReduction, - noiseReductionBasic, - highlight, - shadow, - color, - sharpness, - clarity, - colorChromeEffect, - colorChromeFXBlue, - grainEffect, - bwAdjustment, - bwMagentaGreen, - }, - simulation, - exposure, - iso, -}: { - recipe: FujifilmRecipe - simulation: FilmSimulation - exposure: string - iso: string -}) { - const whiteBalanceFormatted = (whiteBalance?.type ?? 'auto') - .replaceAll('auto', ' ') - .replaceAll('-', ' '); - - const hasBWAdjustments = - Boolean(bwAdjustment) || - Boolean(bwMagentaGreen); - - const renderDataSquare = (label: string, value: string | number = '0') => ( -
    -
    {typeof value === 'number' ? addSign(value) : value}
    -
    - {label} -
    -
    - ); - - return
    -
    -
    - - -
    -
    -
    -
    - DR{dynamicRange ?? 100} -
    -
    - {iso} -
    -
    - {exposure} -
    -
    -
    - {renderDataSquare( - `R${addSign(whiteBalance?.red)} / B${addSign(whiteBalance?.blue)}`, - whiteBalanceFormatted, - )} -
    -
    - {renderDataSquare('Highlight', highlight || random.highlight)} - {renderDataSquare('Shadow', shadow || random.shadow)} -
    -
    - {/* TODO: Confirm color vs saturation label */} - {renderDataSquare('Color', color || random.color)} - {renderDataSquare('Sharpness', sharpness || random.sharpness)} - {renderDataSquare('Clarity', clarity || random.clarity)} -
    -
    - {renderDataSquare('Color Chrome', colorChromeEffect)} - {renderDataSquare('FX Blue', colorChromeFXBlue)} -
    - {grainEffect && -
    - {renderDataSquare( - highISONoiseReduction !== undefined - ? 'High ISO NR' - : 'Noise Reduction', - highISONoiseReduction ?? noiseReductionBasic, - )} - {renderDataSquare( - 'Grain', - // eslint-disable-next-line max-len - `${grainEffect.roughness} / ${grainEffect.size === 'large' ? 'LG' : grainEffect.size === 'small' ? 'SM' : 'OFF'}`, - )} -
    } - {hasBWAdjustments && -
    - {renderDataSquare('BW Adjustment', bwAdjustment)} - {renderDataSquare('BW Magenta Green', bwMagentaGreen)} -
    } -
    -
    -
    ; -} diff --git a/src/photo/PhotoRecipeFrostLightV2.tsx b/src/photo/PhotoRecipeFrostLightV2.tsx deleted file mode 100644 index 5a0e006c..00000000 --- a/src/photo/PhotoRecipeFrostLightV2.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import { FujifilmRecipe } from '@/platforms/fujifilm/recipe'; -import { FilmSimulation } from '@/simulation'; -import PhotoFilmSimulation from '@/simulation/PhotoFilmSimulation'; -import clsx from 'clsx/lite'; -import { IoCloseCircle } from 'react-icons/io5'; - -const addSign = (value = 0) => value < 0 ? value : `+${value}`; - -const getRandomInt = () => { - const randomInt = Math.floor(Math.random() * 4) + 1; - return Math.random() >= 0.5 ? randomInt : -randomInt; -}; - -const random = { - highlight: getRandomInt(), - shadow: getRandomInt(), - color: getRandomInt(), - sharpness: getRandomInt(), - clarity: getRandomInt(), - colorChromeEffect: getRandomInt(), - colorChromeFXBlue: getRandomInt(), -}; - -export default function PhotoRecipeFrostLightV2({ - recipe: { - dynamicRange, - whiteBalance, - highISONoiseReduction, - noiseReductionBasic, - highlight, - shadow, - color, - sharpness, - clarity, - colorChromeEffect, - colorChromeFXBlue, - grainEffect, - bwAdjustment, - bwMagentaGreen, - }, - simulation, - exposure, - iso, -}: { - recipe: FujifilmRecipe - simulation: FilmSimulation - exposure: string - iso: string -}) { - const whiteBalanceFormatted = (whiteBalance?.type ?? 'auto') - .replaceAll('auto', ' ') - .replaceAll('-', ' '); - - const hasBWAdjustments = - Boolean(bwAdjustment) || - Boolean(bwMagentaGreen); - - const renderDataSquare = (label: string, value: string | number = '0') => ( -
    -
    {typeof value === 'number' ? addSign(value) : value}
    -
    - {label} -
    -
    - ); - - return
    -
    -
    - - -
    -
    -
    -
    - DR{dynamicRange ?? 100} -
    -
    - {iso} -
    -
    - {exposure} -
    -
    -
    - {renderDataSquare( - `R${addSign(whiteBalance?.red)} / B${addSign(whiteBalance?.blue)}`, - whiteBalanceFormatted, - )} -
    -
    - {renderDataSquare('Highlight', highlight || random.highlight)} - {renderDataSquare('Shadow', shadow || random.shadow)} -
    -
    - {/* TODO: Confirm color vs saturation label */} - {renderDataSquare('Color', color || random.color)} - {renderDataSquare('Sharpness', sharpness || random.sharpness)} - {renderDataSquare('Clarity', clarity || random.clarity)} -
    -
    - {renderDataSquare('Color Chrome', colorChromeEffect)} - {renderDataSquare('FX Blue', colorChromeFXBlue)} -
    - {grainEffect && -
    - {renderDataSquare( - highISONoiseReduction !== undefined - ? 'High ISO NR' - : 'Noise Reduction', - highISONoiseReduction ?? noiseReductionBasic, - )} - {renderDataSquare( - 'Grain', - // eslint-disable-next-line max-len - `${grainEffect.roughness} / ${grainEffect.size === 'large' ? 'LG' : grainEffect.size === 'small' ? 'SM' : 'OFF'}`, - )} -
    } - {hasBWAdjustments && -
    - {renderDataSquare('BW Adjustment', bwAdjustment)} - {renderDataSquare('BW Magenta Green', bwMagentaGreen)} -
    } -
    -
    -
    ; -} diff --git a/src/photo/PhotoRecipeOverlay.tsx b/src/photo/PhotoRecipeOverlay.tsx index d8ca5cee..d7b74237 100644 --- a/src/photo/PhotoRecipeOverlay.tsx +++ b/src/photo/PhotoRecipeOverlay.tsx @@ -2,7 +2,7 @@ import { FujifilmRecipe } from '@/platforms/fujifilm/recipe'; import { FilmSimulation } from '@/simulation'; import clsx from 'clsx/lite'; import ImageLarge from '@/components/image/ImageLarge'; -import PhotoRecipeFrostLightV2 from './PhotoRecipeFrostLightV2'; +import PhotoRecipe from './PhotoRecipe'; export default function PhotoRecipeOverlay({ backgroundImageUrl, @@ -31,7 +31,7 @@ export default function PhotoRecipeOverlay({ 'absolute inset-0', 'flex items-center justify-center', )}> - Date: Sat, 22 Feb 2025 11:52:17 -0600 Subject: [PATCH 17/38] Relax recipe props --- src/photo/PhotoRecipe.tsx | 4 ++-- src/simulation/PhotoFilmSimulation.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/photo/PhotoRecipe.tsx b/src/photo/PhotoRecipe.tsx index 5465cdac..7bc9be4d 100644 --- a/src/photo/PhotoRecipe.tsx +++ b/src/photo/PhotoRecipe.tsx @@ -44,8 +44,8 @@ export default function PhotoRecipe({ }: { recipe: FujifilmRecipe simulation: FilmSimulation - exposure: string - iso: string + exposure?: string + iso?: string }) { const whiteBalanceFormatted = (whiteBalance?.type ?? 'auto') .replaceAll('auto', ' ') diff --git a/src/simulation/PhotoFilmSimulation.tsx b/src/simulation/PhotoFilmSimulation.tsx index 4248e519..330ece6b 100644 --- a/src/simulation/PhotoFilmSimulation.tsx +++ b/src/simulation/PhotoFilmSimulation.tsx @@ -11,8 +11,8 @@ import EntityLink, { import { LuChevronsUpDown } from 'react-icons/lu'; import clsx from 'clsx/lite'; import { useState } from 'react'; -import PhotoRecipe from '@/photo/PhotoRecipe'; import Tooltip from '@/components/Tooltip'; +import PhotoRecipe from '@/photo/PhotoRecipe'; export default function PhotoFilmSimulation({ simulation, From bf59f0aa099eca2c48817e9aba9fbaf97e19e409 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sat, 22 Feb 2025 12:16:02 -0600 Subject: [PATCH 18/38] Rationalize frosted badge --- src/components/Badge.tsx | 4 ++-- src/photo/PhotoRecipe.tsx | 2 +- src/simulation/PhotoFilmSimulation.tsx | 5 ++++- tailwind.css | 12 ++++++++++-- 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/components/Badge.tsx b/src/components/Badge.tsx index b2cb49a6..84bca0d5 100644 --- a/src/components/Badge.tsx +++ b/src/components/Badge.tsx @@ -13,7 +13,7 @@ export default function Badge({ className?: string type?: 'large' | 'small' | 'text-only' dimContent?: boolean - contrast?: 'low' | 'medium' | 'high' | 'frost' + contrast?: 'low' | 'medium' | 'high' | 'frosted' uppercase?: boolean interactive?: boolean }) { @@ -32,7 +32,7 @@ export default function Badge({ 'text-[0.7rem] font-medium rounded-[0.25rem]', contrast === 'high' ? 'text-invert bg-invert' - : contrast === 'frost' + : contrast === 'frosted' ? 'text-black bg-white/30' : 'text-medium bg-gray-300/30 dark:bg-gray-700/50', interactive && (contrast === 'high' diff --git a/src/photo/PhotoRecipe.tsx b/src/photo/PhotoRecipe.tsx index 7bc9be4d..f48776f6 100644 --- a/src/photo/PhotoRecipe.tsx +++ b/src/photo/PhotoRecipe.tsx @@ -81,7 +81,7 @@ export default function PhotoRecipe({ )}>
    diff --git a/src/simulation/PhotoFilmSimulation.tsx b/src/simulation/PhotoFilmSimulation.tsx index 330ece6b..311edddb 100644 --- a/src/simulation/PhotoFilmSimulation.tsx +++ b/src/simulation/PhotoFilmSimulation.tsx @@ -40,7 +40,10 @@ export default function PhotoFilmSimulation({ label={medium} labelSmall={small} href={pathForFilmSimulation(simulation)} - icon={} + icon={} title={`Film Simulation: ${large}`} type={type} badged={badged} diff --git a/tailwind.css b/tailwind.css index ee234e00..6aa846fe 100644 --- a/tailwind.css +++ b/tailwind.css @@ -86,14 +86,22 @@ } } +/* Text Primitives */ +@utility text-dark { + @apply text-gray-900 +} +@utility text-light { + @apply text-gray-100 +} + /* Text */ @utility text-main { @apply - text-gray-900 dark:text-gray-100 + text-dark dark:text-light } @utility text-invert { @apply - text-white dark:text-black + text-light dark:text-dark } @utility text-medium { @apply From e378f108a19632e9c3912f79f8f41b8e223fc7f4 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sat, 22 Feb 2025 13:11:42 -0600 Subject: [PATCH 19/38] Fix external link icon line break --- src/admin/AdminLink.tsx | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/admin/AdminLink.tsx b/src/admin/AdminLink.tsx index 51e53f84..7817c0df 100644 --- a/src/admin/AdminLink.tsx +++ b/src/admin/AdminLink.tsx @@ -17,22 +17,22 @@ export default function AdminLink({ {...props} href={href} target="blank" - className={clsx( + className={className} + > + - {children} + )}> + {children} + + {externalIcon && +   + + } - {externalIcon && - <> -   - - } ); } From dfca2751725fd74bd4dabeb84c36767aabf19768 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sat, 22 Feb 2025 13:23:40 -0600 Subject: [PATCH 20/38] Refine frost styles --- src/components/primitives/EntityLink.tsx | 4 ++-- src/simulation/PhotoFilmSimulation.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/primitives/EntityLink.tsx b/src/components/primitives/EntityLink.tsx index d0765640..aa494f43 100644 --- a/src/components/primitives/EntityLink.tsx +++ b/src/components/primitives/EntityLink.tsx @@ -48,8 +48,8 @@ export default function EntityLink({ return 'text-dim'; case 'high': return 'text-main'; - case 'frost': - return 'text-invert'; + case 'frosted': + return 'text-black'; default: return 'text-medium'; } diff --git a/src/simulation/PhotoFilmSimulation.tsx b/src/simulation/PhotoFilmSimulation.tsx index 311edddb..01e86a8c 100644 --- a/src/simulation/PhotoFilmSimulation.tsx +++ b/src/simulation/PhotoFilmSimulation.tsx @@ -42,7 +42,7 @@ export default function PhotoFilmSimulation({ href={pathForFilmSimulation(simulation)} icon={} title={`Film Simulation: ${large}`} type={type} From 48fa4e79ba149e04e3b336c75a18b02da17a2ce4 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sat, 22 Feb 2025 13:54:02 -0600 Subject: [PATCH 21/38] Finalize initial recipe layout --- src/components/Badge.tsx | 2 +- src/photo/PhotoRecipe.tsx | 124 ++++++++++++++++++-------------------- 2 files changed, 61 insertions(+), 65 deletions(-) diff --git a/src/components/Badge.tsx b/src/components/Badge.tsx index 84bca0d5..4b2a936b 100644 --- a/src/components/Badge.tsx +++ b/src/components/Badge.tsx @@ -33,7 +33,7 @@ export default function Badge({ contrast === 'high' ? 'text-invert bg-invert' : contrast === 'frosted' - ? 'text-black bg-white/30' + ? 'text-black bg-white/30 border border-white/20' : 'text-medium bg-gray-300/30 dark:bg-gray-700/50', interactive && (contrast === 'high' ? 'hover:opacity-70' diff --git a/src/photo/PhotoRecipe.tsx b/src/photo/PhotoRecipe.tsx index f48776f6..fcd17ea9 100644 --- a/src/photo/PhotoRecipe.tsx +++ b/src/photo/PhotoRecipe.tsx @@ -2,6 +2,7 @@ import { FujifilmRecipe } from '@/platforms/fujifilm/recipe'; import { FilmSimulation } from '@/simulation'; import PhotoFilmSimulation from '@/simulation/PhotoFilmSimulation'; import clsx from 'clsx/lite'; +import { ReactNode } from 'react'; import { IoCloseCircle } from 'react-icons/io5'; const addSign = (value = 0) => value < 0 ? value : `+${value}`; @@ -51,27 +52,32 @@ export default function PhotoRecipe({ .replaceAll('auto', ' ') .replaceAll('-', ' '); - const hasBWAdjustments = - Boolean(bwAdjustment) || - Boolean(bwMagentaGreen); + const renderRow = (children: ReactNode) => +
    {children}
    ; - const renderDataSquare = (label: string, value: string | number = '0') => ( + const renderDataSquare = ( + value: ReactNode, + label?: string, + className?: string, + ) => (
    {typeof value === 'number' ? addSign(value) : value}
    -
    {label} -
    +
    }
    ); return
    -
    -
    - DR{dynamicRange ?? 100} -
    -
    - {iso} -
    -
    - {exposure} -
    -
    -
    + {renderRow(<> + {renderDataSquare(`DR${dynamicRange ?? 100}`)} + {renderDataSquare(iso)} + {renderDataSquare(exposure)} + )} + {renderRow(<> {renderDataSquare( - `R${addSign(whiteBalance?.red)} / B${addSign(whiteBalance?.blue)}`, whiteBalanceFormatted, + `R${addSign(whiteBalance?.red)} / B${addSign(whiteBalance?.blue)}`, + 'basis-2/3', )} -
    -
    - {renderDataSquare('Highlight', highlight || random.highlight)} - {renderDataSquare('Shadow', shadow || random.shadow)} -
    -
    - {/* TODO: Confirm color vs saturation label */} - {renderDataSquare('Color', color || random.color)} - {renderDataSquare('Sharpness', sharpness || random.sharpness)} - {renderDataSquare('Clarity', clarity || random.clarity)} -
    -
    - {renderDataSquare('Color Chrome', colorChromeEffect)} - {renderDataSquare('FX Blue', colorChromeFXBlue)} -
    - {grainEffect && -
    - {renderDataSquare( - highISONoiseReduction !== undefined - ? 'High ISO NR' - : 'Noise Reduction', - highISONoiseReduction ?? noiseReductionBasic, - )} - {renderDataSquare( - 'Grain', - // eslint-disable-next-line max-len - `${grainEffect.roughness} / ${grainEffect.size === 'large' ? 'LG' : grainEffect.size === 'small' ? 'SM' : 'OFF'}`, - )} -
    } - {hasBWAdjustments && -
    - {renderDataSquare('BW Adjustment', bwAdjustment)} - {renderDataSquare('BW Magenta Green', bwMagentaGreen)} -
    } + {renderDataSquare( + highISONoiseReduction ?? noiseReductionBasic, + 'ISO NR', + 'basis-1/3', + )} + )} + {renderRow(<> + {renderDataSquare(highlight || random.highlight, 'Highlight')} + {renderDataSquare(shadow || random.shadow, 'Shadow')} + )} + {renderRow(<> + {renderDataSquare(color || random.color, 'Color')} + {renderDataSquare(sharpness || random.sharpness, 'Sharpness')} + {renderDataSquare(clarity || random.clarity, 'Clarity')} + )} + {renderRow(<> + {renderDataSquare(colorChromeEffect, 'Color Chrome')} + {renderDataSquare(colorChromeFXBlue, 'FX Blue')} + )} + {renderRow(<> + {renderDataSquare( + <> + {grainEffect?.roughness === 'strong' + ? 'Str' + : grainEffect?.roughness === 'weak' + ? 'Wk' + : 'OFF'} + {' / '} + {grainEffect?.size === 'large' + ? 'LG' + : grainEffect?.size === 'small' + ? 'SM' : 'OFF'} + , + 'Grain', + )} + {renderDataSquare(bwAdjustment, 'BW')} + {renderDataSquare(bwMagentaGreen, 'BW M/G')} + )}
    ; From 7474f293ab06699c04bb75e6fb2056983f9de4ff Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sat, 22 Feb 2025 14:07:38 -0600 Subject: [PATCH 22/38] Create new recipe overlay overview --- app/admin/recipe/page.tsx | 37 +++++++++++++++++++++++++++--- src/photo/PhotoRecipeOverlay.tsx | 39 ++++++++++++++++---------------- 2 files changed, 54 insertions(+), 22 deletions(-) diff --git a/app/admin/recipe/page.tsx b/app/admin/recipe/page.tsx index 8401a97c..2237b8a0 100644 --- a/app/admin/recipe/page.tsx +++ b/app/admin/recipe/page.tsx @@ -1,7 +1,38 @@ import { getPhotos } from '@/photo/db/query'; -import { redirect } from 'next/navigation'; +import PhotoRecipeOverlay from '@/photo/PhotoRecipeOverlay'; export default async function AdminRecipePage() { - const photos = await getPhotos({ limit: 1}); - redirect(`/admin/recipe/${photos[0].id}`); + const photos = await getPhotos({ tag: 'favs', limit: 4}); + const photosHidden = await getPhotos({ hidden: 'only', limit: 1 }); + const { filmSimulation } = photosHidden[0]; + const { fujifilmRecipe } = photosHidden[0]; + return ( +
    + + + {photos.map(photo => + , + )} +
    ); } diff --git a/src/photo/PhotoRecipeOverlay.tsx b/src/photo/PhotoRecipeOverlay.tsx index d7b74237..deb1de29 100644 --- a/src/photo/PhotoRecipeOverlay.tsx +++ b/src/photo/PhotoRecipeOverlay.tsx @@ -10,34 +10,35 @@ export default function PhotoRecipeOverlay({ simulation, exposure, iso, + className, }: { - backgroundImageUrl: string + backgroundImageUrl?: string recipe: FujifilmRecipe simulation: FilmSimulation exposure: string iso: string + className?: string }) { return ( -
    +
    + {backgroundImageUrl &&}
    - -
    - -
    +
    ); From 7eef970965007fb8b088acee064175c6d9816a04 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sat, 22 Feb 2025 14:45:33 -0600 Subject: [PATCH 23/38] Finalize frost colors --- app/admin/recipe/page.tsx | 22 +++++++++++----------- src/components/Badge.tsx | 2 +- src/photo/PhotoRecipe.tsx | 11 ++++++----- 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/app/admin/recipe/page.tsx b/app/admin/recipe/page.tsx index 2237b8a0..af6cbb9f 100644 --- a/app/admin/recipe/page.tsx +++ b/app/admin/recipe/page.tsx @@ -7,7 +7,17 @@ export default async function AdminRecipePage() { const { filmSimulation } = photosHidden[0]; const { fujifilmRecipe } = photosHidden[0]; return ( -
    +
    + {photos.map(photo => + , + )} - {photos.map(photo => - , - )}
    ); } diff --git a/src/components/Badge.tsx b/src/components/Badge.tsx index 4b2a936b..ddd98811 100644 --- a/src/components/Badge.tsx +++ b/src/components/Badge.tsx @@ -33,7 +33,7 @@ export default function Badge({ contrast === 'high' ? 'text-invert bg-invert' : contrast === 'frosted' - ? 'text-black bg-white/30 border border-white/20' + ? 'text-black bg-neutral-100/30 border border-neutral-200/40' : 'text-medium bg-gray-300/30 dark:bg-gray-700/50', interactive && (contrast === 'high' ? 'hover:opacity-70' diff --git a/src/photo/PhotoRecipe.tsx b/src/photo/PhotoRecipe.tsx index fcd17ea9..7f10dd95 100644 --- a/src/photo/PhotoRecipe.tsx +++ b/src/photo/PhotoRecipe.tsx @@ -61,8 +61,9 @@ export default function PhotoRecipe({ className?: string, ) => (
    @@ -80,7 +81,7 @@ export default function PhotoRecipe({ 'w-[18rem] self-start', 'p-3', 'rounded-lg shadow-2xl', - 'bg-white/60 backdrop-blur-xl border border-white/30', + 'bg-white/60 backdrop-blur-xl border border-neutral-200/30', 'space-y-3', 'text-[13px] text-black', 'saturate-200', @@ -135,7 +136,7 @@ export default function PhotoRecipe({ : grainEffect?.roughness === 'weak' ? 'Wk' : 'OFF'} - {' / '} + {'/'} {grainEffect?.size === 'large' ? 'LG' : grainEffect?.size === 'small' @@ -143,7 +144,7 @@ export default function PhotoRecipe({ , 'Grain', )} - {renderDataSquare(bwAdjustment, 'BW')} + {renderDataSquare(bwAdjustment, 'BW ADJ')} {renderDataSquare(bwMagentaGreen, 'BW M/G')} )}
    From bd733a285a01a74c94f6137d021780d8cc88b0a9 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sat, 22 Feb 2025 18:53:24 -0600 Subject: [PATCH 24/38] Refine and debug recipe layout --- src/photo/PhotoLarge.tsx | 14 ++++++ src/photo/PhotoRecipe.tsx | 75 +++++++++++++++++++------------- src/platforms/fujifilm/index.ts | 7 ++- src/platforms/fujifilm/recipe.ts | 4 +- 4 files changed, 64 insertions(+), 36 deletions(-) diff --git a/src/photo/PhotoLarge.tsx b/src/photo/PhotoLarge.tsx index 95cfd16e..9a8de663 100644 --- a/src/photo/PhotoLarge.tsx +++ b/src/photo/PhotoLarge.tsx @@ -40,6 +40,7 @@ import { LuExpand } from 'react-icons/lu'; import LoaderButton from '@/components/primitives/LoaderButton'; import Tooltip from '@/components/Tooltip'; import ZoomControls, { ZoomControlsRef } from '@/components/image/ZoomControls'; +import PhotoRecipe from './PhotoRecipe'; export default function PhotoLarge({ photo, @@ -142,6 +143,7 @@ export default function PhotoLarge({ const largePhotoContent =
    + {photo.fujifilmRecipe && photo.filmSimulation && +
    + +
    }
    ; const largePhotoContainerClassName = clsx(arePhotosMatted && diff --git a/src/photo/PhotoRecipe.tsx b/src/photo/PhotoRecipe.tsx index 7f10dd95..7147d155 100644 --- a/src/photo/PhotoRecipe.tsx +++ b/src/photo/PhotoRecipe.tsx @@ -1,4 +1,8 @@ -import { FujifilmRecipe } from '@/platforms/fujifilm/recipe'; +import { + FujifilmRecipe, + DEFAULT_GRAIN_EFFECT, + DEFAULT_WHITE_BALANCE, +} from '@/platforms/fujifilm/recipe'; import { FilmSimulation } from '@/simulation'; import PhotoFilmSimulation from '@/simulation/PhotoFilmSimulation'; import clsx from 'clsx/lite'; @@ -25,7 +29,7 @@ const random = { export default function PhotoRecipe({ recipe: { dynamicRange, - whiteBalance, + whiteBalance = DEFAULT_WHITE_BALANCE, highISONoiseReduction, noiseReductionBasic, highlight, @@ -35,21 +39,21 @@ export default function PhotoRecipe({ clarity, colorChromeEffect, colorChromeFXBlue, - grainEffect, + grainEffect = DEFAULT_GRAIN_EFFECT, bwAdjustment, bwMagentaGreen, - }, + } = {}, simulation, - exposure, iso, + exposure, }: { recipe: FujifilmRecipe simulation: FilmSimulation - exposure?: string iso?: string + exposure?: string }) { - const whiteBalanceFormatted = (whiteBalance?.type ?? 'auto') - .replaceAll('auto', ' ') + const whiteBalanceTypeFormatted = whiteBalance.type + .replace(/auto./i, '') .replaceAll('-', ' '); const renderRow = (children: ReactNode) => @@ -61,15 +65,18 @@ export default function PhotoRecipe({ className?: string, ) => (
    -
    {typeof value === 'number' ? addSign(value) : value}
    +
    + {typeof value === 'number' ? addSign(value) : value} +
    {label &&
    {label}
    } @@ -97,20 +104,20 @@ export default function PhotoRecipe({ className="text-black/25" />
    -
    +
    {renderRow(<> {renderDataSquare(`DR${dynamicRange ?? 100}`)} {renderDataSquare(iso)} - {renderDataSquare(exposure)} + {renderDataSquare(exposure ?? '0ev')} )} {renderRow(<> {renderDataSquare( - whiteBalanceFormatted, + whiteBalanceTypeFormatted.toUpperCase(), `R${addSign(whiteBalance?.red)} / B${addSign(whiteBalance?.blue)}`, 'basis-2/3', )} {renderDataSquare( - highISONoiseReduction ?? noiseReductionBasic, + highISONoiseReduction ?? noiseReductionBasic ?? 'OFF', 'ISO NR', 'basis-1/3', )} @@ -125,27 +132,35 @@ export default function PhotoRecipe({ {renderDataSquare(clarity || random.clarity, 'Clarity')} )} {renderRow(<> - {renderDataSquare(colorChromeEffect, 'Color Chrome')} - {renderDataSquare(colorChromeFXBlue, 'FX Blue')} + {renderDataSquare( + colorChromeEffect?.toLocaleUpperCase() ?? 'N/A', + 'Color Chrome', + )} + {renderDataSquare( + colorChromeFXBlue?.toLocaleUpperCase() ?? 'N/A', + 'FX Blue', + )} )} {renderRow(<> {renderDataSquare( - <> - {grainEffect?.roughness === 'strong' - ? 'Str' - : grainEffect?.roughness === 'weak' - ? 'Wk' - : 'OFF'} - {'/'} - {grainEffect?.size === 'large' - ? 'LG' - : grainEffect?.size === 'small' - ? 'SM' : 'OFF'} - , + grainEffect.roughness === 'off' + ? 'NONE' + : <> + {grainEffect.roughness === 'strong' + ? 'STR' + : grainEffect.roughness === 'weak' + ? 'WK' + : 'OFF'} + {'/'} + {grainEffect.size === 'large' + ? 'LG' + : grainEffect.size === 'small' + ? 'SM' : 'OFF'} + , 'Grain', )} - {renderDataSquare(bwAdjustment, 'BW ADJ')} - {renderDataSquare(bwMagentaGreen, 'BW M/G')} + {renderDataSquare(bwAdjustment ?? 0, 'BW ADJ')} + {renderDataSquare(bwMagentaGreen ?? 0, 'BW M/G')} )}
    diff --git a/src/platforms/fujifilm/index.ts b/src/platforms/fujifilm/index.ts index aab37fa6..1fe6ad9c 100644 --- a/src/platforms/fujifilm/index.ts +++ b/src/platforms/fujifilm/index.ts @@ -37,23 +37,22 @@ export const parseFujifilmMakerNote = ( const tagValueSize = bytes.readUInt16LE(index + BYTE_OFFSET_TAG_SIZE); const sendNumbersForDataType = ( - calculateNumberForOffset: (offset: number) => number, + parseNumberAtOffset: (offset: number) => number, sizeInBytes: number, ) => { let values: number[] = []; if (tagValueSize * sizeInBytes <= BYTES_PER_TAG_VALUE) { // Retrieve values if they fit in tag block values = Array.from({ length: tagValueSize }, (_, i) => - calculateNumberForOffset( + parseNumberAtOffset( index + BYTE_OFFSET_TAG_VALUE + i * sizeInBytes, ), ); } else { // Retrieve outside values if they don't fit in tag block const offset = bytes.readUint16LE(index + BYTE_OFFSET_TAG_VALUE); - values = []; for (let i = 0; i < tagValueSize; i++) { - values.push(calculateNumberForOffset(offset + i * sizeInBytes)); + values.push(parseNumberAtOffset(offset + i * sizeInBytes)); } } sendTagNumbers(tagId, values); diff --git a/src/platforms/fujifilm/recipe.ts b/src/platforms/fujifilm/recipe.ts index 527eb0e5..e763f316 100644 --- a/src/platforms/fujifilm/recipe.ts +++ b/src/platforms/fujifilm/recipe.ts @@ -43,13 +43,13 @@ export type FujifilmRecipe = Partial<{ bwMagentaGreen: number }>; -const DEFAULT_WHITE_BALANCE = { +export const DEFAULT_WHITE_BALANCE = { type: 'auto', red: 0, blue: 0, } as const; -const DEFAULT_GRAIN_EFFECT = { +export const DEFAULT_GRAIN_EFFECT = { roughness: 'off', size: 'off', } as const; From 78a4d03f6a6fc22364195844d789b2d1f2924d2c Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sat, 22 Feb 2025 19:10:05 -0600 Subject: [PATCH 25/38] Add basic show/hide recipe behavior --- src/photo/PhotoLarge.tsx | 41 +++++++++++++---- src/simulation/PhotoFilmSimulation.tsx | 61 +++++++------------------- 2 files changed, 48 insertions(+), 54 deletions(-) diff --git a/src/photo/PhotoLarge.tsx b/src/photo/PhotoLarge.tsx index 9a8de663..10366023 100644 --- a/src/photo/PhotoLarge.tsx +++ b/src/photo/PhotoLarge.tsx @@ -32,7 +32,7 @@ import { } from '@/app/config'; import AdminPhotoMenuClient from '@/admin/AdminPhotoMenuClient'; import { RevalidatePhoto } from './InfinitePhotoScroll'; -import { useRef } from 'react'; +import { useRef, useState } from 'react'; import useVisible from '@/utility/useVisible'; import PhotoDate from './PhotoDate'; import { useAppState } from '@/state/AppState'; @@ -41,6 +41,8 @@ import LoaderButton from '@/components/primitives/LoaderButton'; import Tooltip from '@/components/Tooltip'; import ZoomControls, { ZoomControlsRef } from '@/components/image/ZoomControls'; import PhotoRecipe from './PhotoRecipe'; +import { TbChecklist } from 'react-icons/tb'; +import { IoCloseSharp } from 'react-icons/io5'; export default function PhotoLarge({ photo, @@ -89,6 +91,8 @@ export default function PhotoLarge({ const zoomControlsRef = useRef(null); + const [shouldShowRecipe, setShouldShowRecipe] = useState(false); + const { areZoomControlsShown, arePhotosMatted, @@ -165,14 +169,14 @@ export default function PhotoLarge({ priority={priority} /> - {photo.fujifilmRecipe && photo.filmSimulation && + {shouldShowRecipe && photo.fujifilmRecipe && photo.filmSimulation &&
    @@ -289,11 +293,30 @@ export default function PhotoLarge({
  • {photo.exposureCompensationFormatted ?? '0ev'}
  • {showSimulation && photo.filmSimulation && - } +
    + + {photo.fujifilmRecipe && + + + } +
    } }
    -
    - } - title={`Film Simulation: ${large}`} - type={type} - badged={badged} - contrast={contrast} - prefetch={prefetch} - hoverEntity={countOnHover} - iconWide - /> - {recipe && - - - } -
    - {recipe && shouldShowRecipe && - } -
    + } + title={`Film Simulation: ${large}`} + type={type} + badged={badged} + contrast={contrast} + prefetch={prefetch} + hoverEntity={countOnHover} + iconWide + /> ); } From 920d14980c9e1e5f60e6d3f1573b2197fffc6b4f Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sat, 22 Feb 2025 23:10:48 -0600 Subject: [PATCH 26/38] Refine recipe close functionality --- src/photo/PhotoLarge.tsx | 33 +++++++++--------- src/photo/PhotoRecipe.tsx | 73 ++++++++++++++++++++------------------- 2 files changed, 53 insertions(+), 53 deletions(-) diff --git a/src/photo/PhotoLarge.tsx b/src/photo/PhotoLarge.tsx index 10366023..3a537a4c 100644 --- a/src/photo/PhotoLarge.tsx +++ b/src/photo/PhotoLarge.tsx @@ -179,6 +179,7 @@ export default function PhotoLarge({ simulation={photo.filmSimulation} iso={photo.isoFormatted} exposure={photo.exposureCompensationFormatted} + onClose={() => setShouldShowRecipe(false)} />
    }
    ; @@ -299,23 +300,21 @@ export default function PhotoLarge({ prefetch={prefetchRelatedLinks} /> {photo.fujifilmRecipe && - - - } + }
    } }
    value < 0 ? value : `+${value}`; -const getRandomInt = () => { - const randomInt = Math.floor(Math.random() * 4) + 1; - return Math.random() >= 0.5 ? randomInt : -randomInt; -}; - -const random = { - highlight: getRandomInt(), - shadow: getRandomInt(), - color: getRandomInt(), - sharpness: getRandomInt(), - clarity: getRandomInt(), - colorChromeEffect: getRandomInt(), - colorChromeFXBlue: getRandomInt(), -}; - export default function PhotoRecipe({ recipe: { dynamicRange, @@ -42,16 +29,25 @@ export default function PhotoRecipe({ grainEffect = DEFAULT_GRAIN_EFFECT, bwAdjustment, bwMagentaGreen, - } = {}, + }, simulation, iso, exposure, + onClose, }: { recipe: FujifilmRecipe simulation: FilmSimulation iso?: string exposure?: string + onClose?: () => void }) { + const ref = useRef(null); + + useClickInsideOutside({ + htmlElements: [ref], + onClickOutside: onClose, + }); + const whiteBalanceTypeFormatted = whiteBalance.type .replace(/auto./i, '') .replaceAll('-', ' '); @@ -83,25 +79,30 @@ export default function PhotoRecipe({
    ); - return
    -
    + return ( +
    - } + onClick={onClose} + className={clsx( + 'link p-0 m-0 h-4!', + 'text-black/40 active:text-black/75', + )} />
    @@ -123,13 +124,13 @@ export default function PhotoRecipe({ )} )} {renderRow(<> - {renderDataSquare(highlight || random.highlight, 'Highlight')} - {renderDataSquare(shadow || random.shadow, 'Shadow')} + {renderDataSquare(highlight, 'Highlight')} + {renderDataSquare(shadow, 'Shadow')} )} {renderRow(<> - {renderDataSquare(color || random.color, 'Color')} - {renderDataSquare(sharpness || random.sharpness, 'Sharpness')} - {renderDataSquare(clarity || random.clarity, 'Clarity')} + {renderDataSquare(color, 'Color')} + {renderDataSquare(sharpness, 'Sharpness')} + {renderDataSquare(clarity, 'Clarity')} )} {renderRow(<> {renderDataSquare( @@ -164,5 +165,5 @@ export default function PhotoRecipe({ )}
    -
    ; + ); } From c31e9ab8776ce88a2760756857a94ee00c2076e4 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sun, 23 Feb 2025 00:02:13 -0600 Subject: [PATCH 27/38] Refine recipe trigger on mobile --- src/photo/PhotoLarge.tsx | 4 ++++ src/photo/PhotoRecipe.tsx | 7 +++++-- src/photo/form/index.ts | 1 + src/utility/useClickInsideOutside.ts | 4 ++-- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/photo/PhotoLarge.tsx b/src/photo/PhotoLarge.tsx index 3a537a4c..590d7f31 100644 --- a/src/photo/PhotoLarge.tsx +++ b/src/photo/PhotoLarge.tsx @@ -92,6 +92,7 @@ export default function PhotoLarge({ const zoomControlsRef = useRef(null); const [shouldShowRecipe, setShouldShowRecipe] = useState(false); + const recipeButtonRef = useRef(null); const { areZoomControlsShown, @@ -180,6 +181,7 @@ export default function PhotoLarge({ iso={photo.isoFormatted} exposure={photo.exposureCompensationFormatted} onClose={() => setShouldShowRecipe(false)} + externalTriggerRef={recipeButtonRef} />
    }
    ; @@ -301,6 +303,8 @@ export default function PhotoLarge({ /> {photo.fujifilmRecipe &&
    ; diff --git a/src/photo/PhotoRecipe.tsx b/src/photo/PhotoRecipe.tsx index 18d25255..b881fe57 100644 --- a/src/photo/PhotoRecipe.tsx +++ b/src/photo/PhotoRecipe.tsx @@ -12,10 +12,12 @@ import useClickInsideOutside from '@/utility/useClickInsideOutside'; import clsx from 'clsx/lite'; import { ReactNode, useRef, RefObject } from 'react'; import { IoCloseCircle } from 'react-icons/io5'; +import { motion } from 'framer-motion'; const addSign = (value = 0) => value < 0 ? value : `+${value}`; export default function PhotoRecipe({ + ref: refExternal, recipe: { dynamicRange, whiteBalance = DEFAULT_WHITE_BALANCE, @@ -38,6 +40,7 @@ export default function PhotoRecipe({ onClose, externalTriggerRef, }: { + ref?: RefObject recipe: FujifilmRecipe simulation: FilmSimulation iso?: string @@ -48,7 +51,7 @@ export default function PhotoRecipe({ const ref = useRef(null); useClickInsideOutside({ - htmlElements: [ref, externalTriggerRef], + htmlElements: [ref, refExternal, externalTriggerRef], onClickOutside: onClose, }); @@ -67,11 +70,12 @@ export default function PhotoRecipe({
    -
    +
    {typeof value === 'number' ? addSign(value) : value}
    {label &&
    @@ -149,26 +156,17 @@ export default function PhotoRecipe({ )} {renderRow(<> {renderDataSquare( - grainEffect.roughness === 'off' - ? 'NONE' - : <> - {grainEffect.roughness === 'strong' - ? 'STR' - : grainEffect.roughness === 'weak' - ? 'WK' - : 'OFF'} - {'/'} - {grainEffect.size === 'large' - ? 'LG' - : grainEffect.size === 'small' - ? 'SM' : 'OFF'} - , - 'Grain', + grainEffect.roughness.toLocaleUpperCase(), + grainEffect.size === 'large' + ? 'Large Grain' + : grainEffect.size === 'small' + ? 'Small Grain' + : 'Grain', )} {renderDataSquare(bwAdjustment ?? 0, 'BW ADJ')} {renderDataSquare(bwMagentaGreen ?? 0, 'BW M/G')} )}
    -
    + ); } diff --git a/src/photo/useRecipeState.ts b/src/photo/useRecipeState.ts index f6af4935..678163b0 100644 --- a/src/photo/useRecipeState.ts +++ b/src/photo/useRecipeState.ts @@ -6,9 +6,12 @@ import { import { usePathname } from 'next/navigation'; import { SEARCH_PARAM_SHOW } from '@/app/paths'; import { useSearchParams } from 'next/navigation'; -import { useCallback, useRef, useState } from 'react'; +import { RefObject, useCallback, useEffect, useRef, useState } from 'react'; +import { isElementEntirelyInViewport } from '@/utility/dom'; -export default function useRecipeState() { +export default function useRecipeState( + ref?: RefObject, +) { const pathname = usePathname(); const params = useSearchParams(); @@ -54,6 +57,12 @@ export default function useRecipeState() { } }, [pathComponents, photoId, shouldShowRecipe]); + useEffect(() => { + if (shouldShowRecipe && !isElementEntirelyInViewport(ref?.current)) { + ref?.current?.scrollIntoView({ behavior: 'smooth' }); + } + }, [ref, shouldShowRecipe]); + return { toggleRecipe, recipeButtonRef, diff --git a/src/utility/dom.ts b/src/utility/dom.ts new file mode 100644 index 00000000..a8e92ecd --- /dev/null +++ b/src/utility/dom.ts @@ -0,0 +1,20 @@ +export const isElementEntirelyInViewport = ( + element?: HTMLElement | null, +) => { + if (element) { + const rect = element.getBoundingClientRect(); + return ( + rect.top >= 0 && + rect.left >= 0 && + rect.bottom <= ( + window.innerHeight || + document.documentElement.clientHeight + ) && + rect.right <= ( + window.innerWidth || document.documentElement.clientWidth + ) + ); + } else { + return false; + } +}; From 34667efedf0aaaaa0b0d88f80298fd2175d80baf Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sun, 23 Feb 2025 19:18:55 -0600 Subject: [PATCH 32/38] Adjust DR schema, refine recipe behavior --- src/photo/PhotoLarge.tsx | 31 ++++++------ src/photo/PhotoRecipe.tsx | 28 +++-------- src/photo/useRecipeState.ts | 64 +++++++++++++++--------- src/platforms/fujifilm/recipe.ts | 85 +++++++++++++++++++++----------- 4 files changed, 121 insertions(+), 87 deletions(-) diff --git a/src/photo/PhotoLarge.tsx b/src/photo/PhotoLarge.tsx index 36b51122..1ec3f2ed 100644 --- a/src/photo/PhotoLarge.tsx +++ b/src/photo/PhotoLarge.tsx @@ -98,12 +98,16 @@ export default function PhotoLarge({ const showZoomControls = showZoomControlsProp && areZoomControlsShown; - const recipeRef = useRef(null); + const refRecipe = useRef(null); + const refRecipeTrigger = useRef(null); const { shouldShowRecipe, toggleRecipe, - recipeButtonRef, - } = useRecipeState(recipeRef); + hideRecipe, + } = useRecipeState({ + ref: refRecipe, + refTrigger: refRecipeTrigger, + }); const tags = sortTags(photo.tags, primaryTag); @@ -173,23 +177,22 @@ export default function PhotoLarge({ priority={priority} /> - -
    +
    + {shouldShowRecipe && photo.fujifilmRecipe && photo.filmSimulation && } -
    - + +
    ; const largePhotoContainerClassName = clsx(arePhotosMatted && @@ -309,7 +312,7 @@ export default function PhotoLarge({ /> {photo.fujifilmRecipe &&