diff --git a/__tests__/nikon.test.ts b/__tests__/nikon.test.ts new file mode 100644 index 00000000..09265c33 --- /dev/null +++ b/__tests__/nikon.test.ts @@ -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(); + }); + }); +}); diff --git a/app/film/[film]/[photoId]/page.tsx b/app/film/[film]/[photoId]/page.tsx index cd6e2f47..5dc9cebf 100644 --- a/app/film/[film]/[photoId]/page.tsx +++ b/app/film/[film]/[photoId]/page.tsx @@ -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 ( > & EntityLinkExternalProps) { const { getFilmCount } = useCategoryCounts(); @@ -37,6 +39,7 @@ export default function PhotoFilm({ hoverQueryOptions={{ film }} icon={ 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 diff --git a/src/film/index.tsx b/src/film/index.tsx index 237b1488..b7788507 100644 --- a/src/film/index.tsx +++ b/src/film/index.tsx @@ -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: , + }); }); - return films - .map(film => ({ - ...film, - label: labelForFilm(film.value).large, - icon: , - })) - .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: , + }); + } + }); + } + + return filmOptions.sort((a, b) => a.value.localeCompare(b.value)); }; diff --git a/src/photo/PhotoLarge.tsx b/src/photo/PhotoLarge.tsx index 3ab69843..68d3b099 100644 --- a/src/photo/PhotoLarge.tsx +++ b/src/photo/PhotoLarge.tsx @@ -406,6 +406,7 @@ export default function PhotoLarge({ 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, ]); diff --git a/src/photo/form/index.ts b/src/photo/form/index.ts index b405cc01..f8a3ba6c 100644 --- a/src/photo/form/index.ts +++ b/src/photo/form/index.ts @@ -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, diff --git a/src/photo/form/server.ts b/src/photo/form/server.ts index 251fa731..8c3f73ba 100644 --- a/src/photo/form/server.ts +++ b/src/photo/form/server.ts @@ -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> => { let title: string | undefined = exifr?.title?.value; diff --git a/src/photo/server.ts b/src/photo/server.ts index 4f97fc51..28206b6f 100644 --- a/src/photo/server.ts +++ b/src/photo/server.ts @@ -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, diff --git a/src/platforms/nikon/index.ts b/src/platforms/nikon/index.ts new file mode 100644 index 00000000..488e25ab --- /dev/null +++ b/src/platforms/nikon/index.ts @@ -0,0 +1,4 @@ +export const MAKE_NIKON = 'NIKON CORPORATION'; + +export const isMakeNikon = (make?: string) => + make?.toUpperCase() === MAKE_NIKON; diff --git a/src/platforms/nikon/server.ts b/src/platforms/nikon/server.ts new file mode 100644 index 00000000..10219b30 --- /dev/null +++ b/src/platforms/nikon/server.ts @@ -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; + } +}; diff --git a/src/platforms/nikon/simulation.ts b/src/platforms/nikon/simulation.ts new file mode 100644 index 00000000..93cfa140 --- /dev/null +++ b/src/platforms/nikon/simulation.ts @@ -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 = { + '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 = { + '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; +}; diff --git a/src/recipe/PhotoRecipeOverlay.tsx b/src/recipe/PhotoRecipeOverlay.tsx index fd6db1ff..2a06824c 100644 --- a/src/recipe/PhotoRecipeOverlay.tsx +++ b/src/recipe/PhotoRecipeOverlay.tsx @@ -25,6 +25,7 @@ export default function PhotoRecipeOverlay({ title, data, film, + make, onClose, isOnPhoto = true, }: RecipeProps & { @@ -163,6 +164,7 @@ export default function PhotoRecipeOverlay({