feat: add Nikon Z Picture Control support (#361)

* feat: add Nikon Z Picture Control support

* refactor: Consolidate Fujifilm and Nikon MakerNote parsing logic and remove unused code and comments.

* fix: decode film parameter before fetching photo data.

* fix: decode URL-encoded film parameters for improved routing

* feat: Add default set of Nikon Picture Controls for consistent labels and allow for Picture Controls not already in the database to be picked from the drop down.

* feat: Add camera make context to film components for conditional Fujifilm simulation display
This commit is contained in:
Rich Manalang 2025-12-28 11:23:33 -08:00 committed by GitHub
parent 2f06441755
commit ec55005df2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 353 additions and 47 deletions

50
__tests__/nikon.test.ts Normal file
View File

@ -0,0 +1,50 @@
import { getNikonPictureControlFromMakerNote } from '@/platforms/nikon/simulation';
describe('Nikon', () => {
describe('parsing', () => {
it('extracts Picture Control Name from PictureControlData (0x0023)', () => {
// Construct a mock Nikon MakerNote
// Header: "Nikon\x00\x02\x00\x00\x00" (10 bytes)
const header = Buffer.from('Nikon\x00\x02\x00\x00\x00', 'ascii');
// TIFF Header at offset 10
// II (Little Endian)
const tiffHeader = Buffer.from([0x49, 0x49, 0x2A, 0x00, 0x08, 0x00, 0x00, 0x00]);
// IFD at offset 10 + 8 = 18
// Count: 1 tag
const ifdCount = Buffer.from([0x01, 0x00]);
// Tag: PictureControlData (0x0023)
// Type: Undefined (7)
// Count: 108
// Value/Offset: Offset to data
const tagId = Buffer.from([0x23, 0x00]);
const tagType = Buffer.from([0x07, 0x00]);
const tagCount = Buffer.from([0x6C, 0x00, 0x00, 0x00]); // 108
const tagOffset = Buffer.from([0x16, 0x00, 0x00, 0x00]); // 22
const tag = Buffer.concat([tagId, tagType, tagCount, tagOffset]);
// Data: 108 bytes
// 0-3: Version
// 4-7: Version
// 8-27: Name (20 bytes)
const data = Buffer.alloc(108);
data.write('0310', 0);
data.write('0310', 4);
data.write('Standard\0\0\0', 8); // Name at offset 8
const makerNote = Buffer.concat([header, tiffHeader, ifdCount, tag, data]);
const pictureControl = getNikonPictureControlFromMakerNote(makerNote);
expect(pictureControl).toBe('Standard');
});
it('returns undefined for invalid header', () => {
const makerNote = Buffer.from('Canon\x00\x02\x00\x00\x00', 'ascii');
const pictureControl = getNikonPictureControlFromMakerNote(makerNote);
expect(pictureControl).toBeUndefined();
});
});
});

View File

@ -67,20 +67,21 @@ export default async function PhotoFilmPage({
params,
}: PhotoFilmProps) {
const { photoId, film } = await params;
const decodedFilm = decodeURIComponent(film);
const { photo, photos, photosGrid, indexNumber } =
await getPhotosNearIdCachedCached(photoId, film);
await getPhotosNearIdCachedCached(photoId, decodedFilm);
if (!photo) { redirect(PATH_ROOT); }
const { count, dateRange } = await getPhotosMetaCached({ film: film });
const { count, dateRange } = await getPhotosMetaCached({ film: decodedFilm });
return (
<PhotoDetailPage {...{
photo,
photos,
photosGrid,
film: film,
film: decodedFilm,
indexNumber,
count,
dateRange,

View File

@ -66,11 +66,12 @@ export default async function FilmPage({
params,
}: FilmProps) {
const { film } = await params;
const decodedFilm = decodeURIComponent(film);
const [
photos,
{ count, dateRange },
] = await getPhotosFilmDataCachedCached(film);
] = await getPhotosFilmDataCachedCached(decodedFilm);
if (photos.length === 0) { redirect(PATH_ROOT); }

View File

@ -15,14 +15,15 @@ export default function FilmOverview({
dateRange?: PhotoDateRangePostgres,
animateOnFirstLoadOnly?: boolean,
}) {
const decodedFilm = decodeURIComponent(film);
return (
<PhotoGridContainer {...{
cacheKey: `film-${film}`,
photos,
count,
film,
film: decodedFilm,
header: <FilmHeader {...{
film,
film: decodedFilm,
photos,
count,
dateRange,

View File

@ -14,6 +14,7 @@ import useCategoryCounts from '@/category/useCategoryCounts';
export default function PhotoFilm({
film,
make,
type = 'icon-last',
badged = true,
contrast = 'low',
@ -22,6 +23,7 @@ export default function PhotoFilm({
...props
}: {
film: string
make?: string
} & Partial<ComponentProps<typeof PhotoRecipeOverlayButton>>
& EntityLinkExternalProps) {
const { getFilmCount } = useCategoryCounts();
@ -37,6 +39,7 @@ export default function PhotoFilm({
hoverQueryOptions={{ film }}
icon={<PhotoFilmIcon
film={film}
make={make}
className={clsx(
contrast === 'frosted' && 'text-black',
type === 'icon-only'

View File

@ -1,6 +1,7 @@
/* eslint-disable max-len */
import { CSSProperties } from 'react';
import { labelForFilm } from '.';
import { isMakeFujifilm } from '@/platforms/fujifilm';
const INTRINSIC_WIDTH = 28;
const INTRINSIC_WIDTH_FALLBACK = 14;
@ -12,11 +13,13 @@ const FALLBACK_ICON = <g>
export default function PhotoFilmIcon({
film,
make,
height = INTRINSIC_HEIGHT,
className,
style,
}: {
film?: string
make?: string
height?: number
className?: string
style?: CSSProperties
@ -24,6 +27,9 @@ export default function PhotoFilmIcon({
const simulationIcon = (() => {
// Self-calling switch function and non-fragment groups
// necessary for ImageResponse compatibility
if (make && !isMakeFujifilm(make)) {
return undefined;
}
switch (film) {
case 'monochrome': return <g>
<path fillRule="evenodd" clipRule="evenodd" d="M16.25 14H22.5C22.6381 14 22.75 13.8881 22.75 13.75V10.5202C22.75 10.4539 22.7763 10.3903 22.8232 10.3434L25.677 7.48989C25.7238 7.44301 25.7502 7.37942 25.7502 7.31311V4.25C25.7502 4.11193 25.6383 4 25.5002 4H16.25C16.1119 4 16 4.11194 16 4.25002L16.0002 6.49998C16.0002 6.63806 15.8882 6.75 15.7502 6.75H14.7502C14.6121 6.75 14.5002 6.86192 14.5002 6.99999L14.5 11C14.5 11.1381 14.6119 11.25 14.75 11.25H15.75C15.8881 11.25 16 11.3619 16 11.5V13.75C16 13.8881 16.1119 14 16.25 14ZM18.75 5H17V6.5H18.75V5ZM17 11.5H18.75V13H17V11.5ZM21.75 5H20V6.5H21.75V5ZM20 11.5H21.75V13H20V11.5ZM24.75 5H23V6.5H24.75V5Z" fill="currentColor"/>

View File

@ -12,6 +12,10 @@ import {
FUJIFILM_SIMULATION_FORM_INPUT_OPTIONS,
labelForFujifilmSimulation,
} from '@/platforms/fujifilm/simulation';
import {
isStringNikonPictureControl,
labelForNikonPictureControl,
} from '@/platforms/nikon/simulation';
import {
deparameterize,
formatCount,
@ -31,14 +35,19 @@ export const labelForFilm = (film: string) => {
const simulationLabel = labelForFujifilmSimulation(film as any);
if (simulationLabel) {
return simulationLabel;
} else {
const filmFormatted = deparameterize(film);
return {
small: filmFormatted,
medium: filmFormatted,
large: filmFormatted,
};
}
// Use Nikon Picture Control text when recognized
if (isStringNikonPictureControl(film)) {
return labelForNikonPictureControl(film);
}
const filmFormatted = deparameterize(film);
return {
small: filmFormatted,
medium: filmFormatted,
large: filmFormatted,
};
};
export const sortFilms = (
@ -109,32 +118,36 @@ export const photoHasFilmData = (photo: Photo) =>
Boolean(photo.film);
export const convertFilmsForForm = (
_films: Films = [],
films: Films = [],
includeAllFujifilmSimulations?: boolean,
currentFilm?: string,
make?: string,
): AnnotatedTag[] => {
const films: AnnotatedTag[] = includeAllFujifilmSimulations
? FUJIFILM_SIMULATION_FORM_INPUT_OPTIONS
.map(({ value }) => ({ value }))
: [];
const filmOptions: AnnotatedTag[] = [];
_films.forEach(({ film, count }) => {
const index = films.findIndex(f => f.value === film);
const meta = {
annotation: formatCount(count),
annotationAria: formatCountDescriptive(count),
};
if (index === -1) {
films.push({ value: film, ...meta });
} else {
films[index] = { ...films[index], ...meta };
}
if (currentFilm && !films.some(f => f.film === currentFilm)) {
films.push({ film: currentFilm } as FilmWithMeta);
}
films.forEach(item => {
filmOptions.push({
value: item.film,
label: labelForFilm(item.film).large,
icon: <PhotoFilmIcon film={item.film} make={make} />,
});
});
return films
.map(film => ({
...film,
label: labelForFilm(film.value).large,
icon: <PhotoFilmIcon film={film.value} />,
}))
.sort((a, b) => a.value.localeCompare(b.value));
if (includeAllFujifilmSimulations) {
FUJIFILM_SIMULATION_FORM_INPUT_OPTIONS.forEach(({ value: simulation }) => {
if (!filmOptions.some(option => option.value === simulation)) {
filmOptions.push({
value: simulation,
label: labelForFilm(simulation).large,
icon: <PhotoFilmIcon film={simulation} make={make} />,
});
}
});
}
return filmOptions.sort((a, b) => a.value.localeCompare(b.value));
};

View File

@ -406,6 +406,7 @@ export default function PhotoLarge({
<PhotoFilm
ref={refPhotoFilm}
film={photo.film}
make={photo.make}
prefetch={prefetchRelatedLinks}
{...photo.recipeData && !photo.recipeTitle && {
toggleRecipeOverlay,

View File

@ -108,6 +108,9 @@ export default function PhotoForm({
useState(getFormErrors(initialPhotoForm));
const [formActionErrorMessage, setFormActionErrorMessage] = useState('');
const [detectedFilm, setDetectedFilm] =
useState(initialPhotoForm.film);
const [albumTitles, setAlbumTitles] = useState(photoAlbumTitles
.sort((a, b) => a.localeCompare(b))
.join(','));
@ -168,6 +171,10 @@ export default function PhotoForm({
};
});
if (updatedExifData?.film) {
setDetectedFilm(updatedExifData.film);
}
if (changedKeys.length > 0) {
const fields = convertFormKeysToLabels(changedKeys);
toastSuccess(`Updated EXIF fields: ${fields.join(', ')}`, 8000);
@ -342,7 +349,12 @@ export default function PhotoForm({
FORM_METADATA_ENTRIES_BY_SECTION(
convertTagsForForm(uniqueTags, appText),
convertRecipesForForm(uniqueRecipes),
convertFilmsForForm(uniqueFilms, isMakeFujifilm(formData.make)),
convertFilmsForForm(
uniqueFilms,
isMakeFujifilm(formData.make),
detectedFilm,
formData.make,
),
aiContent !== undefined,
shouldStripGpsData,
), [
@ -351,6 +363,7 @@ export default function PhotoForm({
uniqueRecipes,
uniqueFilms,
formData.make,
detectedFilm,
aiContent,
shouldStripGpsData,
]);

View File

@ -151,8 +151,8 @@ const FORM_METADATA = (
film: {
section: 'exif',
label: 'film',
note: 'Intended for Fujifilm cameras and analog scans',
noteShort: 'Fujifilm cameras / analog scans',
note: 'Intended for Fujifilm / Nikon cameras and analog scans',
noteShort: 'Fujifilm / Nikon cameras / analog scans',
tagOptions: filmOptions,
tagOptionsLimit: 1,
shouldNotOverwriteWithNullDataOnSync: true,

View File

@ -14,10 +14,12 @@ import { FujifilmRecipe } from '@/platforms/fujifilm/recipe';
import { FujifilmSimulation } from '@/platforms/fujifilm/simulation';
import type { ExifData, ExifTags } from 'ts-exif-parser';
import { NikonPictureControl } from '@/platforms/nikon/simulation';
export const convertExifToFormData = (
exif: ExifData,
exifr?: any,
film?: FujifilmSimulation,
film?: FujifilmSimulation | NikonPictureControl,
recipeData?: FujifilmRecipe,
): Partial<Record<keyof PhotoExif, string | undefined>> => {
let title: string | undefined = exifr?.title?.value;

View File

@ -19,6 +19,11 @@ import {
FujifilmRecipe,
getFujifilmRecipeFromMakerNote,
} from '@/platforms/fujifilm/recipe';
import {
getNikonPictureControlFromMakerNote,
NikonPictureControl,
} from '@/platforms/nikon/simulation';
import { isExifForNikon } from '@/platforms/nikon/server';
import {
deletePhoto,
getRecipeTitleForData,
@ -63,7 +68,7 @@ export const extractImageDataFromBlobPath = async (
let dataExif: ExifData | undefined;
let dataExifr: any | undefined;
let film: FujifilmSimulation | undefined;
let film: FujifilmSimulation | NikonPictureControl | undefined;
let recipe: FujifilmRecipe | undefined;
let blurData: string | undefined;
let imageResizedBase64: string | undefined;
@ -87,16 +92,20 @@ export const extractImageDataFromBlobPath = async (
dataExif = parser.parse();
dataExifr = await exifr.parse(fileBytes, { xmp: true });
// Capture film simulation for Fujifilm cameras
if (isExifForFujifilm(dataExif)) {
// Capture film simulation for Fujifilm or Picture Control for Nikon
if (isExifForFujifilm(dataExif) || isExifForNikon(dataExif)) {
// Parse exif data again with binary fields
// in order to access MakerNote tag
parser.enableBinaryFields(true);
const exifDataBinary = parser.parse();
const makerNote = exifDataBinary.tags?.MakerNote;
if (Buffer.isBuffer(makerNote)) {
film = getFujifilmSimulationFromMakerNote(makerNote);
recipe = getFujifilmRecipeFromMakerNote(makerNote);
if (isExifForFujifilm(dataExif)) {
film = getFujifilmSimulationFromMakerNote(makerNote);
recipe = getFujifilmRecipeFromMakerNote(makerNote);
} else if (isExifForNikon(dataExif)) {
film = getNikonPictureControlFromMakerNote(makerNote);
}
}
}
@ -144,7 +153,6 @@ export const extractImageDataFromBlobPath = async (
error,
};
};
const generateBase64 = async (
image: ArrayBuffer,
middleware?: (sharp: Sharp) => Sharp,

View File

@ -0,0 +1,4 @@
export const MAKE_NIKON = 'NIKON CORPORATION';
export const isMakeNikon = (make?: string) =>
make?.toUpperCase() === MAKE_NIKON;

View File

@ -0,0 +1,76 @@
import type { ExifData } from 'ts-exif-parser';
import { isMakeNikon } from '.';
// Nikon MakerNote Header
const NIKON_MAKERNOTE_HEADER = 'Nikon\x00\x02\x00\x00\x00';
const HEADER_SIZE = 18;
export const isExifForNikon = (data: ExifData) => isMakeNikon(data.tags?.Make);
export const parseNikonMakerNote = (
bytes: Buffer,
sendTagValue: (tagId: number, value: any) => void,
) => {
// Check for Nikon header
if (bytes.length < 10 || bytes.toString('ascii', 0, 5) !== 'Nikon') {
return;
}
// Assume Type 3 for Z series
// Skip 10 bytes header
const baseOffset = 10;
const tiffStart = 10;
if (bytes.length < tiffStart + 8) return;
const isLE = bytes.toString('hex', tiffStart, tiffStart + 2) === '4949';
const readUInt16 = (offset: number) => isLE ? bytes.readUInt16LE(offset) : bytes.readUInt16BE(offset);
const readUInt32 = (offset: number) => isLE ? bytes.readUInt32LE(offset) : bytes.readUInt32BE(offset);
const ifdOffset = readUInt32(tiffStart + 4);
let currentOffset = tiffStart + ifdOffset;
if (currentOffset >= bytes.length) return;
const tagCount = readUInt16(currentOffset);
currentOffset += 2;
for (let i = 0; i < tagCount; i++) {
if (currentOffset + 12 > bytes.length) break;
const tagId = readUInt16(currentOffset);
const type = readUInt16(currentOffset + 2);
const count = readUInt32(currentOffset + 4);
// Value offset or value itself
const valueOffsetOrData = currentOffset + 8;
let value: any;
// We only care about ASCII strings (Type 2) and Undefined (Type 7) for now
if (type === 2) {
let offset = valueOffsetOrData;
if (count > 4) {
offset = tiffStart + readUInt32(valueOffsetOrData);
}
if (offset + count <= bytes.length) {
value = bytes.toString('ascii', offset, offset + count - 1); // -1 to remove null terminator
}
} else if (type === 7) {
let offset = valueOffsetOrData;
if (count > 4) {
offset = tiffStart + readUInt32(valueOffsetOrData);
}
if (offset + count <= bytes.length) {
value = bytes.subarray(offset, offset + count);
}
}
if (value !== undefined) {
sendTagValue(tagId, value);
}
currentOffset += 12;
}
};

View File

@ -0,0 +1,124 @@
import { parseNikonMakerNote } from './server';
const TAG_ID_PICTURE_CONTROL_DATA = 0x0023;
export type NikonPictureControl = string;
export interface NikonPictureControlLabel {
small: string
medium: string
large: string
}
const NIKON_PICTURE_CONTROL_LABELS: Record<string, NikonPictureControlLabel> = {
'auto': { small: 'Auto', medium: 'Auto', large: 'Auto' },
'standard': { small: 'Standard', medium: 'Standard', large: 'Standard' },
'neutral': { small: 'Neutral', medium: 'Neutral', large: 'Neutral' },
'vivid': { small: 'Vivid', medium: 'Vivid', large: 'Vivid' },
'monochrome': { small: 'Monochrome', medium: 'Monochrome', large: 'Monochrome' },
'portrait': { small: 'Portrait', medium: 'Portrait', large: 'Portrait' },
'landscape': { small: 'Landscape', medium: 'Landscape', large: 'Landscape' },
'flat': { small: 'Flat', medium: 'Flat', large: 'Flat' },
'rich-tone-portrait': { small: 'Rich Tone Portrait', medium: 'Rich Tone Portrait', large: 'Rich Tone Portrait' },
'deep-tone-monochrome': { small: 'Deep Tone Mono', medium: 'Deep Tone Mono', large: 'Deep Tone Monochrome' },
'flat-monochrome': { small: 'Flat Mono', medium: 'Flat Mono', large: 'Flat Monochrome' },
'dream': { small: 'Dream', medium: 'Dream', large: 'Dream' },
'morning': { small: 'Morning', medium: 'Morning', large: 'Morning' },
'pop': { small: 'Pop', medium: 'Pop', large: 'Pop' },
'sunday': { small: 'Sunday', medium: 'Sunday', large: 'Sunday' },
'somber': { small: 'Somber', medium: 'Somber', large: 'Somber' },
'dramatic': { small: 'Dramatic', medium: 'Dramatic', large: 'Dramatic' },
'silence': { small: 'Silence', medium: 'Silence', large: 'Silence' },
'bleached': { small: 'Bleached', medium: 'Bleached', large: 'Bleached' },
'melancholic': { small: 'Melancholic', medium: 'Melancholic', large: 'Melancholic' },
'pure': { small: 'Pure', medium: 'Pure', large: 'Pure' },
'denim': { small: 'Denim', medium: 'Denim', large: 'Denim' },
'toy': { small: 'Toy', medium: 'Toy', large: 'Toy' },
'sepia': { small: 'Sepia', medium: 'Sepia', large: 'Sepia' },
'blue': { small: 'Blue', medium: 'Blue', large: 'Blue' },
'red': { small: 'Red', medium: 'Red', large: 'Red' },
'pink': { small: 'Pink', medium: 'Pink', large: 'Pink' },
'charcoal': { small: 'Charcoal', medium: 'Charcoal', large: 'Charcoal' },
'graphite': { small: 'Graphite', medium: 'Graphite', large: 'Graphite' },
'binary': { small: 'Binary', medium: 'Binary', large: 'Binary' },
'carbon': { small: 'Carbon', medium: 'Carbon', large: 'Carbon' },
};
const NIKON_STRING_TO_MODE: Record<string, string> = {
'AUTO': 'auto',
'STANDARD': 'standard',
'NEUTRAL': 'neutral',
'VIVID': 'vivid',
'MONOCHROME': 'monochrome',
'PORTRAIT': 'portrait',
'LANDSCAPE': 'landscape',
'FLAT': 'flat',
'RICH TONE PORTRAIT': 'rich-tone-portrait',
'DEEP TONE MONOCHROME': 'deep-tone-monochrome',
'FLAT MONOCHROME': 'flat-monochrome',
'DREAM': 'dream',
'MORNING': 'morning',
'POP': 'pop',
'SUNDAY': 'sunday',
'SOMBER': 'somber',
'DRAMATIC': 'dramatic',
'SILENCE': 'silence',
'BLEACHED': 'bleached',
'MELANCHOLIC': 'melancholic',
'PURE': 'pure',
'DENIM': 'denim',
'TOY': 'toy',
'SEPIA': 'sepia',
'BLUE': 'blue',
'RED': 'red',
'PINK': 'pink',
'CHARCOAL': 'charcoal',
'GRAPHITE': 'graphite',
'BINARY': 'binary',
'CARBON': 'carbon',
};
export const isStringNikonPictureControl = (film?: string): film is NikonPictureControl =>
film !== undefined &&
Object.keys(NIKON_PICTURE_CONTROL_LABELS).includes(film);
export const labelForNikonPictureControl = (film: NikonPictureControl): NikonPictureControlLabel =>
NIKON_PICTURE_CONTROL_LABELS[film] ?? {
small: film,
medium: film,
large: film,
};
export const getNikonPictureControlFromMakerNote = (
bytes: Buffer,
): NikonPictureControl | undefined => {
let pictureControl: string | undefined;
parseNikonMakerNote(
bytes,
(tag, value) => {
if (tag === TAG_ID_PICTURE_CONTROL_DATA && Buffer.isBuffer(value)) {
// Picture Control Name is at offset 8, length 20
if (value.length >= 28) {
const name = value.toString('ascii', 8, 28);
// Remove null bytes and trim
pictureControl = name.replace(/\0/g, '').trim();
}
}
},
);
if (pictureControl) {
if (NIKON_STRING_TO_MODE[pictureControl]) {
return NIKON_STRING_TO_MODE[pictureControl];
}
const upper = pictureControl.toUpperCase();
if (NIKON_STRING_TO_MODE[upper]) {
return NIKON_STRING_TO_MODE[upper];
}
return pictureControl;
}
return undefined;
};

View File

@ -25,6 +25,7 @@ export default function PhotoRecipeOverlay({
title,
data,
film,
make,
onClose,
isOnPhoto = true,
}: RecipeProps & {
@ -163,6 +164,7 @@ export default function PhotoRecipeOverlay({
<div className="flex items-center gap-1.5">
<PhotoFilm
film={film}
make={isOnPhoto ? make : undefined}
contrast="frosted"
className={clsx(
'translate-y-[-0.5px]',

View File

@ -24,7 +24,8 @@ export interface RecipeProps {
data: FujifilmRecipe
film: string
iso?: string
exposure?: string
exposure?: string
make?: string
}
export const formatRecipe = (recipe?: string) =>