Vercel/src/photo/server.ts
Rich Manalang ec55005df2
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
2025-12-28 14:23:33 -05:00

296 lines
8.4 KiB
TypeScript

import {
deleteFilesWithPrefix,
getFileNamePartsFromStorageUrl,
} from '@/platforms/storage';
import { convertFormDataToPhotoDbInsert } from '@/photo/form';
import {
FujifilmSimulation,
getFujifilmSimulationFromMakerNote,
} from '@/platforms/fujifilm/simulation';
import { ExifData, ExifParserFactory } from 'ts-exif-parser';
import { PhotoFormData } from './form';
import sharp, { Sharp } from 'sharp';
import {
GEO_PRIVACY_ENABLED,
PRESERVE_ORIGINAL_UPLOADS,
} from '@/app/config';
import { isExifForFujifilm } from '@/platforms/fujifilm/server';
import {
FujifilmRecipe,
getFujifilmRecipeFromMakerNote,
} from '@/platforms/fujifilm/recipe';
import {
getNikonPictureControlFromMakerNote,
NikonPictureControl,
} from '@/platforms/nikon/simulation';
import { isExifForNikon } from '@/platforms/nikon/server';
import {
deletePhoto,
getRecipeTitleForData,
updateAllMatchingRecipeTitles,
} from '@/photo/query';
import { PhotoDbInsert } from '.';
import { convertExifToFormData } from './form/server';
import { getColorFieldsForPhotoForm } from './color/server';
import exifr from 'exifr';
import { getCompatibleExifValue } from '@/utility/exif';
const IMAGE_WIDTH_BLUR = 200;
const IMAGE_WIDTH_DEFAULT = 200;
const IMAGE_QUALITY_DEFAULT = 80;
export const extractImageDataFromBlobPath = async (
blobPath: string, {
includeInitialPhotoFields,
generateBlurData,
generateResizedImage,
updateColorFields = true,
}: {
includeInitialPhotoFields?: boolean
generateBlurData?: boolean
generateResizedImage?: boolean
updateColorFields?: boolean
} = {},
): Promise<{
blobId?: string
formDataFromExif?: Partial<PhotoFormData>
imageResizedBase64?: string
shouldStripGpsData?: boolean
fileBytes?: ArrayBuffer
error?: string
}> => {
const url = decodeURIComponent(blobPath);
const {
fileExtension: extension,
fileId: blobId,
} = getFileNamePartsFromStorageUrl(url);
let dataExif: ExifData | undefined;
let dataExifr: any | undefined;
let film: FujifilmSimulation | NikonPictureControl | undefined;
let recipe: FujifilmRecipe | undefined;
let blurData: string | undefined;
let imageResizedBase64: string | undefined;
let shouldStripGpsData = false;
let error: string | undefined;
const fileBytes = blobPath
? await fetch(url, { cache: 'no-store' }).then(res => res.arrayBuffer())
.catch(e => {
error = `Error fetching image from ${url}: "${e.message}"`;
return undefined;
})
: undefined;
try {
if (fileBytes) {
const parser = ExifParserFactory.create(Buffer.from(fileBytes));
// Data for form
parser.enableBinaryFields(false);
dataExif = parser.parse();
dataExifr = await exifr.parse(fileBytes, { xmp: true });
// 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);
}
}
}
if (generateBlurData) {
blurData = await blurImage(fileBytes);
}
if (generateResizedImage) {
imageResizedBase64 = await resizeImage(fileBytes);
}
shouldStripGpsData = GEO_PRIVACY_ENABLED && (
Boolean(getCompatibleExifValue('GPSLatitude', dataExif, dataExifr)) ||
Boolean(getCompatibleExifValue('GPSLongitude', dataExif, dataExifr))
);
}
} catch (e) {
error = `Error extracting image data from ${url}: "${e}"`;
}
if (error) { console.log(error); }
const colorFields = updateColorFields
? await getColorFieldsForPhotoForm(url)
: undefined;
return {
blobId,
...dataExif && {
formDataFromExif: {
...includeInitialPhotoFields && {
hidden: 'false',
favorite: 'false',
extension,
url,
},
...generateBlurData && { blurData },
...convertExifToFormData(dataExif, dataExifr, film, recipe),
...colorFields,
},
},
imageResizedBase64,
shouldStripGpsData,
fileBytes,
error,
};
};
const generateBase64 = async (
image: ArrayBuffer,
middleware?: (sharp: Sharp) => Sharp,
) =>
(middleware ? middleware(sharp(image)) : sharp(image))
.withMetadata()
.toFormat('jpeg', { quality: IMAGE_QUALITY_DEFAULT })
.toBuffer()
.then(data => `data:image/jpeg;base64,${data.toString('base64')}`);
const resizeImage = async (
image: ArrayBuffer,
width = IMAGE_WIDTH_DEFAULT,
) =>
generateBase64(image, sharp => sharp
.resize(width),
);
const blurImage = async (image: ArrayBuffer) =>
generateBase64(image, sharp => sharp
.resize(IMAGE_WIDTH_BLUR)
.modulate({ saturation: 1.15 })
.blur(4),
);
export const getImageBase64FromUrl = async (url: string) =>
fetch(decodeURIComponent(url))
.then(res => res.arrayBuffer())
.then(buffer => generateBase64(buffer))
.catch(e => {
console.log(`Error getting image base64 from URL (${url})`, e);
return '';
});
export const resizeImageFromUrl = async (
url: string,
width?: number,
) =>
fetch(decodeURIComponent(url))
.then(res => res.arrayBuffer())
.then(buffer => resizeImage(buffer, width))
.catch(e => {
console.log(`Error resizing image from URL (${url})`, e);
return '';
});
export const blurImageFromUrl = async (url: string) =>
fetch(decodeURIComponent(url))
.then(res => res.arrayBuffer())
.then(buffer => blurImage(buffer))
.catch(e => {
console.log(`Error blurring image from URL (${url})`, e);
return '';
});
export const resizeImageToBytes = async (
image: ArrayBuffer,
width: number,
quality = IMAGE_QUALITY_DEFAULT,
) =>
sharp(image)
.resize(width)
.toFormat('jpeg', { quality })
.toBuffer();
const GPS_NULL_STRING = '-';
export const removeGpsData = async (image: ArrayBuffer) =>
sharp(image)
.withExifMerge({
IFD3: {
GPSMapDatum: GPS_NULL_STRING,
GPSLatitude: GPS_NULL_STRING,
GPSLongitude: GPS_NULL_STRING,
GPSDateStamp: GPS_NULL_STRING,
GPSDateTime: GPS_NULL_STRING,
GPSTimeStamp: GPS_NULL_STRING,
GPSAltitude: GPS_NULL_STRING,
GPSSatellites: GPS_NULL_STRING,
GPSAreaInformation: GPS_NULL_STRING,
GPSSpeed: GPS_NULL_STRING,
GPSImgDirection: GPS_NULL_STRING,
GPSDestLatitude: GPS_NULL_STRING,
GPSDestLongitude: GPS_NULL_STRING,
GPSDestBearing: GPS_NULL_STRING,
GPSDestDistance: GPS_NULL_STRING,
GPSHPositioningError: GPS_NULL_STRING,
},
})
.toFormat('jpeg', { quality: PRESERVE_ORIGINAL_UPLOADS ? 95 : 80 })
.toBuffer();
export const convertFormDataToPhotoDbInsertAndLookupRecipeTitle =
async (...args: Parameters<typeof convertFormDataToPhotoDbInsert>):
Promise<ReturnType<typeof convertFormDataToPhotoDbInsert>> => {
const photo = convertFormDataToPhotoDbInsert(...args);
if (photo.recipeData && !photo.recipeTitle && photo.film) {
const recipeTitle = await getRecipeTitleForData(
photo.recipeData,
photo.film,
);
// Only replace recipe title when a new one is found
if (recipeTitle) {
photo.recipeTitle = recipeTitle;
}
}
return photo;
};
export const propagateRecipeTitleIfNecessary = async (
formData: FormData,
photo: PhotoDbInsert,
) => {
if (
formData.get('applyRecipeTitleGlobally') === 'true' &&
// Only propagate recipe title if set by user before lookup
formData.get('recipeTitle') &&
photo.recipeTitle &&
photo.recipeData &&
photo.film
) {
await updateAllMatchingRecipeTitles(
photo.recipeTitle,
photo.recipeData,
photo.film,
);
}
};
export const deletePhotoAndFiles = async (
photoId: string,
photoUrl: string,
) =>
deletePhoto(photoId)
.then(() => {
const { fileNameBase } = getFileNamePartsFromStorageUrl(photoUrl);
return deleteFilesWithPrefix(fileNameBase);
});