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:
parent
2f06441755
commit
ec55005df2
50
__tests__/nikon.test.ts
Normal file
50
__tests__/nikon.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
|
||||
@ -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); }
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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"/>
|
||||
|
||||
@ -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 {
|
||||
}
|
||||
|
||||
// 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));
|
||||
};
|
||||
|
||||
@ -406,6 +406,7 @@ export default function PhotoLarge({
|
||||
<PhotoFilm
|
||||
ref={refPhotoFilm}
|
||||
film={photo.film}
|
||||
make={photo.make}
|
||||
prefetch={prefetchRelatedLinks}
|
||||
{...photo.recipeData && !photo.recipeTitle && {
|
||||
toggleRecipeOverlay,
|
||||
|
||||
@ -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,
|
||||
]);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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)) {
|
||||
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,
|
||||
|
||||
4
src/platforms/nikon/index.ts
Normal file
4
src/platforms/nikon/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export const MAKE_NIKON = 'NIKON CORPORATION';
|
||||
|
||||
export const isMakeNikon = (make?: string) =>
|
||||
make?.toUpperCase() === MAKE_NIKON;
|
||||
76
src/platforms/nikon/server.ts
Normal file
76
src/platforms/nikon/server.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
124
src/platforms/nikon/simulation.ts
Normal file
124
src/platforms/nikon/simulation.ts
Normal 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;
|
||||
};
|
||||
@ -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]',
|
||||
|
||||
@ -25,6 +25,7 @@ export interface RecipeProps {
|
||||
film: string
|
||||
iso?: string
|
||||
exposure?: string
|
||||
make?: string
|
||||
}
|
||||
|
||||
export const formatRecipe = (recipe?: string) =>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user