Add recipe details to OG images

This commit is contained in:
Sam Becker 2025-03-10 01:16:34 -05:00
parent 6be19f4979
commit 19aeaf4ef3
12 changed files with 166 additions and 30 deletions

View File

@ -134,6 +134,7 @@ export default function FieldSetWithStatus({
value={value}
options={tagOptions}
onChange={onChange}
showMenuOnDelete={tagOptionsLimit === 1}
className={clsx(Boolean(error) && 'error')}
readOnly={readOnly || pending || loading}
placeholder={placeholder}

View File

@ -15,6 +15,7 @@ export default function TagInput({
value = '',
options = [],
onChange,
showMenuOnDelete,
className,
readOnly,
placeholder,
@ -26,6 +27,7 @@ export default function TagInput({
value?: string
options?: AnnotatedTag[]
onChange?: (value: string) => void
showMenuOnDelete?: boolean
className?: string
readOnly?: boolean
placeholder?: string
@ -202,7 +204,9 @@ export default function TagInput({
case 'Backspace':
if (inputText === '' && selectedOptions.length > 0) {
removeOption(selectedOptions[selectedOptions.length - 1]);
hideMenu();
if (!showMenuOnDelete) {
hideMenu();
}
}
break;
case 'Escape':
@ -217,6 +221,7 @@ export default function TagInput({
}, [
inputText,
removeOption,
showMenuOnDelete,
hideMenu,
selectedOptions,
selectedOptionIndex,

View File

@ -53,9 +53,8 @@ export default function CameraImageResponse({
marginRight: height * .015,
}}
/>,
}}>
{formatCameraText(camera).toLocaleUpperCase()}
</ImageCaption>
title: formatCameraText(camera).toLocaleUpperCase(),
}} />
</ImageContainer>
);
}

View File

@ -41,9 +41,8 @@ export default function FilmSimulationImageResponse({
height={height * .081}
style={{ transform: `translateY(${height * .001}px)`}}
/>,
}}>
{labelForFilmSimulation(simulation).medium.toLocaleUpperCase()}
</ImageCaption>
title: labelForFilmSimulation(simulation).medium.toLocaleUpperCase(),
}} />
</ImageContainer>
);
}

View File

@ -39,9 +39,8 @@ export default function FocalLengthImageResponse({
marginRight: height * .01,
}}
/>,
}}>
{formatFocalLength(focal)}
</ImageCaption>
title: formatFocalLength(focal),
}} />
</ImageContainer>
);
}

View File

@ -25,9 +25,12 @@ export default function HomeImageResponse({
height,
}}
/>
<ImageCaption {...{ width, height, fontFamily }}>
{SITE_DOMAIN_OR_TITLE}
</ImageCaption>
<ImageCaption {...{
width,
height,
fontFamily,
title: SITE_DOMAIN_OR_TITLE,
}} />
</ImageContainer>
);
}

View File

@ -47,9 +47,8 @@ export default function PhotoImageResponse({
...photo.make === 'Apple' && { icon: <AiFillApple style={{
marginRight: height * .01,
}} /> },
}}>
{caption}
</ImageCaption>}
title: caption,
}} />}
</ImageContainer>
);
};

View File

@ -5,6 +5,9 @@ import ImageContainer from './components/ImageContainer';
import type { NextImageSize } from '@/platforms/next-image';
import { formatTag } from '@/tag';
import { TbChecklist } from 'react-icons/tb';
import { generateRecipeText, getPhotoWithRecipeFromPhotos } from '@/recipe';
const MAX_RECIPE_LINES = 8;
export default function RecipeImageResponse({
recipe,
@ -18,7 +21,22 @@ export default function RecipeImageResponse({
width: NextImageSize
height: number
fontFamily: string
}) {
}) {
const photo = getPhotoWithRecipeFromPhotos(photos);
let recipeLines = photo?.recipeData && photo.filmSimulation
? generateRecipeText({
recipe: photo.recipeData,
simulation: photo.filmSimulation!,
iso: photo.iso!.toString(),
})
: [];
if (recipeLines && recipeLines.length > MAX_RECIPE_LINES) {
recipeLines = recipeLines.slice(0, MAX_RECIPE_LINES);
recipeLines[MAX_RECIPE_LINES - 1] = '•••';
}
return (
<ImageContainer solidBackground={photos.length === 0}>
<ImagePhotoGrid
@ -26,12 +44,22 @@ export default function RecipeImageResponse({
photos,
width,
height,
gap: 0,
}}
/>
<div
tw="flex absolute inset-0"
style={{
background:
'linear-gradient(to right, rgba(0, 0, 0, .5) 40%, transparent 75%)',
}}
/>
<ImageCaption {...{
width,
height,
fontFamily,
legacyBottomAlignment: false,
gap: '0',
icon: <TbChecklist
size={height * .087}
style={{
@ -39,8 +67,38 @@ export default function RecipeImageResponse({
marginRight: height * .02,
}}
/>,
title: formatTag(recipe).toLocaleUpperCase(),
}}>
{formatTag(recipe).toLocaleUpperCase()}
{photo?.recipeData &&
<div
tw="opacity-60"
style={{
display: 'flex',
flexDirection: 'column',
paddingTop: height * .03,
lineHeight: 1.22,
}}
>
{recipeLines.map(text => (
<div tw="flex" key={text}>
<div
tw="flex"
style={{
width: height * .141,
}}
/>
<div style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
width: '100%',
flexGrow: 1,
}}>
{text}
</div>
</div>
))}
</div>}
</ImageCaption>
</ImageContainer>
);

View File

@ -48,9 +48,8 @@ export default function TagImageResponse({
marginRight: height * .02,
}}
/>,
}}>
{formatTag(tag).toLocaleUpperCase()}
</ImageCaption>
title: formatTag(tag).toLocaleUpperCase(),
}} />
</ImageContainer>
);
}

View File

@ -7,19 +7,26 @@ export default function ImageCaption({
height,
fontFamily,
icon,
title,
gap = '1rem', // Mimic mono font space metric
children,
legacyBottomAlignment = OG_TEXT_BOTTOM_ALIGNMENT,
}: {
width: number
height: number
fontFamily: string
icon?: ReactNode
children: ReactNode
title?: string
gap?: string
children?: ReactNode
legacyBottomAlignment?: boolean
}) {
const paddingEdge = height * .07;
const paddingContent = height * .6;
return (
<div style={{
display: 'flex',
flexDirection: 'column',
position: 'absolute',
paddingLeft: height * .0875,
paddingRight: height * .0875,
@ -27,11 +34,11 @@ export default function ImageCaption({
backgroundBlendMode: 'multiply',
fontFamily,
fontSize: height *.08,
gap: '1rem', // Mimic mono font space metric
gap,
lineHeight: 1.2,
left: 0,
right: 0,
...OG_TEXT_BOTTOM_ALIGNMENT
...legacyBottomAlignment
? {
paddingTop: paddingContent,
paddingBottom: paddingEdge,
@ -49,7 +56,7 @@ export default function ImageCaption({
display: 'flex',
alignItems: 'center',
gap: height * .034,
...OG_TEXT_BOTTOM_ALIGNMENT
...legacyBottomAlignment
? { marginBottom: -height * .008 }
: { marginTop: -height * .008 },
}}>
@ -63,9 +70,10 @@ export default function ImageCaption({
whiteSpace: 'nowrap',
}}
>
{children}
{title}
</div>
</div>
{children}
</div>
);
}

View File

@ -6,7 +6,13 @@ import clsx from 'clsx/lite';
import { ReactNode, RefObject } from 'react';
import { IoCloseCircle } from 'react-icons/io5';
import { motion } from 'framer-motion';
import { addSign, formatRecipe, formatWhiteBalance, RecipeProps } from '.';
import {
addSign,
formatNoiseReduction,
formatRecipe,
formatWhiteBalance,
RecipeProps,
} from '.';
export default function PhotoRecipeOverlay({
ref,
@ -23,8 +29,6 @@ export default function PhotoRecipeOverlay({
const {
dynamicRange,
whiteBalance,
highISONoiseReduction,
noiseReductionBasic,
highlight,
shadow,
color,
@ -118,7 +122,7 @@ export default function PhotoRecipeOverlay({
'basis-2/3',
)}
{renderDataSquare(
highISONoiseReduction ?? noiseReductionBasic ?? 'OFF',
formatNoiseReduction(recipe),
'ISO NR',
'basis-1/3',
)}

View File

@ -8,6 +8,7 @@ import {
} from '@/utility/string';
import { FujifilmRecipe } from '@/platforms/fujifilm/recipe';
import { FilmSimulation } from '@/simulation';
import { labelForFilmSimulation } from '@/platforms/fujifilm/simulation';
export type RecipeWithCount = {
recipe: string
@ -53,6 +54,45 @@ export const descriptionForRecipePhotos = (
explicitDateRange,
);
export const generateRecipeText = ({
recipe,
simulation,
}: RecipeProps) => {
const lines = [
`${labelForFilmSimulation(simulation).large.toLocaleUpperCase()}`,
`DR${recipe.dynamicRange.development} NR${formatNoiseReduction(recipe)}`,
// eslint-disable-next-line max-len
`${formatWhiteBalance(recipe).toLocaleUpperCase()} ${formatWhiteBalanceColor(recipe)}`,
];
if (recipe.highlight || recipe.shadow) {
// eslint-disable-next-line max-len
lines.push(`HIGH/SHAD ${addSign(recipe.highlight)}/${addSign(recipe.shadow)}`);
}
// eslint-disable-next-line max-len
lines.push(`COL${addSign(recipe.color)} SHARP${addSign(recipe.sharpness)} CLAR${addSign(recipe.clarity)}`);
if (recipe.colorChromeEffect) {
lines.push(`CHROME ${recipe.colorChromeEffect.toLocaleUpperCase()}`);
}
if (recipe.colorChromeFXBlue) {
lines.push(`FX BLUE ${recipe.colorChromeFXBlue.toLocaleUpperCase()}`);
}
if (recipe.grainEffect.roughness !== 'off') {
lines.push(`GRAIN ${formatGrain(recipe)}`);
}
if (recipe.bwAdjustment || recipe.bwMagentaGreen) {
// eslint-disable-next-line max-len
lines.push(`BW ADJ ${addSign(recipe.bwAdjustment)} BW M/G ${addSign(recipe.bwMagentaGreen)}`);
}
return lines;
};
export const generateMetaForRecipe = (
recipe: string,
photos: Photo[],
@ -96,3 +136,25 @@ export const formatWhiteBalance = ({ whiteBalance }: FujifilmRecipe) =>
: whiteBalance.type
.replace(/auto./i, '')
.replaceAll('-', ' ');
export const formatWhiteBalanceColor = ({
whiteBalance: { red, blue },
}: FujifilmRecipe) =>
(red || blue)
? `(R${addSign(red)}/B${addSign(blue)})`
: '';
export const formatGrain = ({ grainEffect }: FujifilmRecipe) =>
grainEffect.roughness === 'off'
? 'OFF'
: grainEffect.roughness === 'weak'
? `WEAK/${grainEffect.size.toLocaleUpperCase()}`
: `STRONG/${grainEffect.size.toLocaleUpperCase()}`;
export const formatNoiseReduction = ({
highISONoiseReduction,
noiseReductionBasic,
}: FujifilmRecipe) =>
highISONoiseReduction
? addSign(highISONoiseReduction)
: noiseReductionBasic ?? 'OFF';