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} value={value}
options={tagOptions} options={tagOptions}
onChange={onChange} onChange={onChange}
showMenuOnDelete={tagOptionsLimit === 1}
className={clsx(Boolean(error) && 'error')} className={clsx(Boolean(error) && 'error')}
readOnly={readOnly || pending || loading} readOnly={readOnly || pending || loading}
placeholder={placeholder} placeholder={placeholder}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,6 +5,9 @@ import ImageContainer from './components/ImageContainer';
import type { NextImageSize } from '@/platforms/next-image'; import type { NextImageSize } from '@/platforms/next-image';
import { formatTag } from '@/tag'; import { formatTag } from '@/tag';
import { TbChecklist } from 'react-icons/tb'; import { TbChecklist } from 'react-icons/tb';
import { generateRecipeText, getPhotoWithRecipeFromPhotos } from '@/recipe';
const MAX_RECIPE_LINES = 8;
export default function RecipeImageResponse({ export default function RecipeImageResponse({
recipe, recipe,
@ -18,7 +21,22 @@ export default function RecipeImageResponse({
width: NextImageSize width: NextImageSize
height: number height: number
fontFamily: string 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 ( return (
<ImageContainer solidBackground={photos.length === 0}> <ImageContainer solidBackground={photos.length === 0}>
<ImagePhotoGrid <ImagePhotoGrid
@ -26,12 +44,22 @@ export default function RecipeImageResponse({
photos, photos,
width, width,
height, height,
gap: 0,
}}
/>
<div
tw="flex absolute inset-0"
style={{
background:
'linear-gradient(to right, rgba(0, 0, 0, .5) 40%, transparent 75%)',
}} }}
/> />
<ImageCaption {...{ <ImageCaption {...{
width, width,
height, height,
fontFamily, fontFamily,
legacyBottomAlignment: false,
gap: '0',
icon: <TbChecklist icon: <TbChecklist
size={height * .087} size={height * .087}
style={{ style={{
@ -39,8 +67,38 @@ export default function RecipeImageResponse({
marginRight: height * .02, 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> </ImageCaption>
</ImageContainer> </ImageContainer>
); );

View File

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

View File

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

View File

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

View File

@ -8,6 +8,7 @@ import {
} from '@/utility/string'; } from '@/utility/string';
import { FujifilmRecipe } from '@/platforms/fujifilm/recipe'; import { FujifilmRecipe } from '@/platforms/fujifilm/recipe';
import { FilmSimulation } from '@/simulation'; import { FilmSimulation } from '@/simulation';
import { labelForFilmSimulation } from '@/platforms/fujifilm/simulation';
export type RecipeWithCount = { export type RecipeWithCount = {
recipe: string recipe: string
@ -53,6 +54,45 @@ export const descriptionForRecipePhotos = (
explicitDateRange, 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 = ( export const generateMetaForRecipe = (
recipe: string, recipe: string,
photos: Photo[], photos: Photo[],
@ -96,3 +136,25 @@ export const formatWhiteBalance = ({ whiteBalance }: FujifilmRecipe) =>
: whiteBalance.type : whiteBalance.type
.replace(/auto./i, '') .replace(/auto./i, '')
.replaceAll('-', ' '); .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';