Adjust DR schema, refine recipe behavior

This commit is contained in:
Sam Becker 2025-02-23 19:18:55 -06:00
parent 22d94e1b4b
commit 34667efedf
4 changed files with 121 additions and 87 deletions

View File

@ -98,12 +98,16 @@ export default function PhotoLarge({
const showZoomControls = showZoomControlsProp && areZoomControlsShown;
const recipeRef = useRef<HTMLDivElement>(null);
const refRecipe = useRef<HTMLDivElement>(null);
const refRecipeTrigger = useRef<HTMLButtonElement>(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}
/>
</ZoomControls>
<AnimatePresence>
<div className={clsx(
'absolute inset-0',
'flex items-center justify-center',
)}>
<div className={clsx(
'absolute inset-0',
'flex items-center justify-center',
)}>
<AnimatePresence>
{shouldShowRecipe && photo.fujifilmRecipe && photo.filmSimulation &&
<PhotoRecipe
ref={recipeRef}
ref={refRecipe}
recipe={photo.fujifilmRecipe}
simulation={photo.filmSimulation}
iso={photo.isoFormatted}
exposure={photo.exposureCompensationFormatted}
onClose={toggleRecipe}
externalTriggerRef={recipeButtonRef}
onClose={hideRecipe}
/>}
</div>
</AnimatePresence>
</AnimatePresence>
</div>
</div>;
const largePhotoContainerClassName = clsx(arePhotosMatted &&
@ -309,7 +312,7 @@ export default function PhotoLarge({
/>
{photo.fujifilmRecipe &&
<button
ref={recipeButtonRef}
ref={refRecipeTrigger}
title="Fujifilm Recipe"
onClick={toggleRecipe}
className={clsx(

View File

@ -1,26 +1,21 @@
'use client';
import LoaderButton from '@/components/primitives/LoaderButton';
import {
FujifilmRecipe,
DEFAULT_GRAIN_EFFECT,
DEFAULT_WHITE_BALANCE,
} from '@/platforms/fujifilm/recipe';
import { FujifilmRecipe } from '@/platforms/fujifilm/recipe';
import { FilmSimulation } from '@/simulation';
import PhotoFilmSimulation from '@/simulation/PhotoFilmSimulation';
import useClickInsideOutside from '@/utility/useClickInsideOutside';
import clsx from 'clsx/lite';
import { ReactNode, useRef, RefObject } from 'react';
import { ReactNode, 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,
ref,
recipe: {
dynamicRange,
whiteBalance = DEFAULT_WHITE_BALANCE,
whiteBalance,
highISONoiseReduction,
noiseReductionBasic,
highlight,
@ -30,7 +25,7 @@ export default function PhotoRecipe({
clarity,
colorChromeEffect,
colorChromeFXBlue,
grainEffect = DEFAULT_GRAIN_EFFECT,
grainEffect,
bwAdjustment,
bwMagentaGreen,
},
@ -38,7 +33,6 @@ export default function PhotoRecipe({
iso,
exposure,
onClose,
externalTriggerRef,
}: {
ref?: RefObject<HTMLDivElement | null>
recipe: FujifilmRecipe
@ -46,15 +40,7 @@ export default function PhotoRecipe({
iso?: string
exposure?: string
onClose?: () => void
externalTriggerRef?: RefObject<HTMLElement | null>
}) {
const ref = useRef<HTMLDivElement>(null);
useClickInsideOutside({
htmlElements: [ref, refExternal, externalTriggerRef],
onClickOutside: onClose,
});
const whiteBalanceTypeFormatted = whiteBalance.type
.replace(/auto./i, '')
.replaceAll('-', ' ');
@ -89,7 +75,7 @@ export default function PhotoRecipe({
return (
<motion.div
ref={refExternal ?? ref}
ref={ref}
initial={{ opacity: 0, translateY: -10 }}
animate={{ opacity: 1, translateY: 0 }}
exit={{ opacity: 0, translateY: -10 }}
@ -119,7 +105,7 @@ export default function PhotoRecipe({
</div>
<div className="space-y-2">
{renderRow(<>
{renderDataSquare(`DR${dynamicRange ?? 100}`)}
{renderDataSquare(`DR${dynamicRange.development}`)}
{renderDataSquare(iso)}
{renderDataSquare(exposure ?? '0ev')}
</>)}

View File

@ -6,12 +6,17 @@ import {
import { usePathname } from 'next/navigation';
import { SEARCH_PARAM_SHOW } from '@/app/paths';
import { useSearchParams } from 'next/navigation';
import { RefObject, useCallback, useEffect, useRef, useState } from 'react';
import { RefObject, useCallback, useEffect, useState } from 'react';
import { isElementEntirelyInViewport } from '@/utility/dom';
import useClickInsideOutside from '@/utility/useClickInsideOutside';
export default function useRecipeState(
ref?: RefObject<HTMLDivElement | null>,
) {
export default function useRecipeState({
ref,
refTrigger,
}: {
ref?: RefObject<HTMLElement | null>,
refTrigger?: RefObject<HTMLElement | null>,
}) {
const pathname = usePathname();
const params = useSearchParams();
@ -19,28 +24,14 @@ export default function useRecipeState(
photoId,
...pathComponents
} = getPathComponents(pathname);
const showRecipeInitially =
params.get(SEARCH_PARAM_SHOW) === SEARCH_PARAM_SHOW_RECIPE;
const [shouldShowRecipe, setShouldShowRecipe] = useState(showRecipeInitially);
const recipeButtonRef = useRef<HTMLButtonElement>(null);
const toggleRecipe = useCallback(() => {
if (shouldShowRecipe) {
setShouldShowRecipe(false);
// Only remove query param for photo details
if (photoId) {
window.history.pushState(
null,
'',
pathForPhoto({
photo: photoId,
...pathComponents,
}),
);
}
} else {
const setVisibility = useCallback((shouldShow: boolean) => {
if (shouldShow) {
setShouldShowRecipe(true);
// Only add query param for photo details
if (photoId) {
@ -54,8 +45,32 @@ export default function useRecipeState(
}),
);
}
} else {
setShouldShowRecipe(false);
// Only remove query param for photo details
if (photoId) {
window.history.pushState(
null,
'',
pathForPhoto({
photo: photoId,
...pathComponents,
}),
);
}
}
}, [pathComponents, photoId, shouldShowRecipe]);
}, [pathComponents, photoId]);
const showRecipe = useCallback(() => setVisibility(true), [setVisibility]);
const hideRecipe = useCallback(() => setVisibility(false), [setVisibility]);
const toggleRecipe = useCallback(() =>
setVisibility(!shouldShowRecipe),
[setVisibility, shouldShowRecipe]);
useClickInsideOutside({
htmlElements: [ref, refTrigger],
onClickOutside: hideRecipe,
});
useEffect(() => {
if (shouldShowRecipe && !isElementEntirelyInViewport(ref?.current)) {
@ -64,8 +79,9 @@ export default function useRecipeState(
}, [ref, shouldShowRecipe]);
return {
toggleRecipe,
recipeButtonRef,
shouldShowRecipe,
showRecipe,
hideRecipe,
toggleRecipe,
};
}

View File

@ -1,5 +1,7 @@
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_WHITE_BALANCE = 0x1002;
const TAG_ID_WHITE_BALANCE_FINE_TUNE = 0x100a;
@ -19,41 +21,65 @@ const TAG_ID_BW_MAGENTA_GREEN = 0x104b;
type WeakStrong = 'off' | 'weak' | 'strong';
export type FujifilmRecipe = Partial<{
dynamicRange: number
export type FujifilmRecipe = {
dynamicRange: {
range: 'standard' | 'wide'
// eslint-disable-next-line max-len
setting: 'auto' | 'manual' | 'standard' | 'wide-1' | 'wide-2' | 'film-simulation'
development: number
}
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
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
}>;
bwAdjustment?: number
bwMagentaGreen?: number
};
export const DEFAULT_WHITE_BALANCE = {
const DEFAULT_DYNAMIC_RANGE = {
range: 'standard',
setting: 'auto',
development: 100,
} as const;
const DEFAULT_WHITE_BALANCE = {
type: 'auto',
red: 0,
blue: 0,
} as const;
export const DEFAULT_GRAIN_EFFECT = {
const DEFAULT_GRAIN_EFFECT = {
roughness: 'off',
size: 'off',
} as const;
export const processDynamicRangeSettings = (
value: number,
): FujifilmRecipe['dynamicRange']['setting'] => {
switch (value) {
case 0x001: return 'manual';
case 0x100: return 'standard';
case 0x200: return 'wide-1';
case 0x201: return 'wide-1';
case 0x8000: return 'film-simulation';
default: return 'auto';
}
};
export const processTone = (value: number) =>
value === 0 ? 0 : -(value / 16);
@ -156,25 +182,31 @@ export const processWhiteBalanceComponent = (value: number) => value / 20;
export const getFujifilmRecipeFromMakerNote = (
bytes: Buffer,
): FujifilmRecipe => {
const recipe: FujifilmRecipe = {};
const recipe: FujifilmRecipe = {
dynamicRange: DEFAULT_DYNAMIC_RANGE,
whiteBalance: DEFAULT_WHITE_BALANCE,
grainEffect: DEFAULT_GRAIN_EFFECT,
};
parseFujifilmMakerNote(
bytes,
(tag, numbers) => {
switch (tag) {
case TAG_ID_DYNAMIC_RANGE:
recipe.dynamicRange.range = numbers[0] === 3
? 'wide'
: 'standard';
break;
case TAG_ID_DYNAMIC_RANGE_SETTING:
recipe.dynamicRange.setting = processDynamicRangeSettings(numbers[0]);
break;
case TAG_ID_DEVELOPMENT_DYNAMIC_RANGE:
recipe.dynamicRange = numbers[0];
recipe.dynamicRange.development = 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;
@ -182,8 +214,7 @@ export const getFujifilmRecipeFromMakerNote = (
recipe.highISONoiseReduction = processNoiseReduction(numbers[0]);
break;
case TAG_ID_NOISE_REDUCTION_BASIC:
recipe.noiseReductionBasic =
processNoiseReductionLegacy(numbers[0]);
recipe.noiseReductionBasic = processNoiseReductionLegacy(numbers[0]);
break;
case TAG_ID_HIGHLIGHT:
recipe.highlight = processTone(numbers[0]);
@ -207,11 +238,9 @@ export const getFujifilmRecipeFromMakerNote = (
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: