[web] Exif write-back improvements (#2515)

This commit is contained in:
Manav Rathi
2024-07-22 21:04:32 +05:30
committed by GitHub
9 changed files with 221 additions and 140 deletions

View File

@@ -1,11 +0,0 @@
/**
* @file Types for "clip-bpe-js"
*
* Non exhaustive, only the function we need.
*/
declare module "clip-bpe-js" {
class Tokenizer {
encodeForCLIP(text: string): number[];
}
}

View File

@@ -7,7 +7,6 @@ import type {
} from "@/new/photos/types/metadata";
import { validateAndGetCreationUnixTimeInMicroSeconds } from "@ente/shared/time";
import exifr from "exifr";
import piexif from "piexifjs";
type ParsedEXIFData = Record<string, any> &
Partial<{
@@ -332,67 +331,3 @@ export function getEXIFTime(exifData: ParsedEXIFData): number {
}
return validateAndGetCreationUnixTimeInMicroSeconds(dateTime);
}
export async function updateFileCreationDateInEXIF(
reader: FileReader,
fileBlob: Blob,
updatedDate: Date,
) {
try {
let imageDataURL = await convertImageToDataURL(reader, fileBlob);
imageDataURL =
"data:image/jpeg;base64" +
imageDataURL.slice(imageDataURL.indexOf(","));
const exifObj = piexif.load(imageDataURL);
if (!exifObj["Exif"]) {
exifObj["Exif"] = {};
}
exifObj["Exif"][piexif.ExifIFD.DateTimeOriginal] =
convertToExifDateFormat(updatedDate);
const exifBytes = piexif.dump(exifObj);
const exifInsertedFile = piexif.insert(exifBytes, imageDataURL);
return dataURIToBlob(exifInsertedFile);
} catch (e) {
log.error("updateFileModifyDateInEXIF failed", e);
return fileBlob;
}
}
async function convertImageToDataURL(reader: FileReader, blob: Blob) {
const dataURL = await new Promise<string>((resolve) => {
reader.onload = () => resolve(reader.result as string);
reader.readAsDataURL(blob);
});
return dataURL;
}
function dataURIToBlob(dataURI: string) {
// convert base64 to raw binary data held in a string
// doesn't handle URLEncoded DataURIs - see SO answer #6850276 for code that does this
const byteString = atob(dataURI.split(",")[1]);
// separate out the mime component
const mimeString = dataURI.split(",")[0].split(":")[1].split(";")[0];
// write the bytes of the string to an ArrayBuffer
const ab = new ArrayBuffer(byteString.length);
// create a view into the buffer
const ia = new Uint8Array(ab);
// set the bytes of the buffer to the correct values
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
}
// write the ArrayBuffer to a blob, and you're done
const blob = new Blob([ab], { type: mimeString });
return blob;
}
function convertToExifDateFormat(date: Date) {
return `${date.getFullYear()}:${
date.getMonth() + 1
}:${date.getDate()} ${date.getHours()}:${date.getMinutes()}:${date.getSeconds()}`;
}

View File

@@ -4,6 +4,7 @@ import { FILE_TYPE } from "@/media/file-type";
import { decodeLivePhoto } from "@/media/live-photo";
import type { Metadata } from "@/media/types/file";
import downloadManager from "@/new/photos/services/download";
import { updateExifIfNeededAndPossible } from "@/new/photos/services/exif-update";
import {
exportMetadataDirectoryName,
exportTrashDirectoryName,
@@ -36,7 +37,7 @@ import {
getCollectionUserFacingName,
getNonEmptyPersonalCollections,
} from "utils/collection";
import { getPersonalFiles, getUpdatedEXIFFileForDownload } from "utils/file";
import { getPersonalFiles } from "utils/file";
import { getAllLocalCollections } from "../collectionService";
import { migrateExport } from "./migration";
@@ -970,11 +971,7 @@ class ExportService {
try {
const fileUID = getExportRecordFileUID(file);
const originalFileStream = await downloadManager.getFile(file);
if (!this.fileReader) {
this.fileReader = new FileReader();
}
const updatedFileStream = await getUpdatedEXIFFileForDownload(
this.fileReader,
const updatedFileStream = await updateExifIfNeededAndPossible(
file,
originalFileStream,
);

View File

@@ -1,15 +0,0 @@
export const readAsDataURL = (blob) =>
new Promise<string>((resolve, reject) => {
const fileReader = new FileReader();
fileReader.onload = () => resolve(fileReader.result as string);
fileReader.onerror = () => reject(fileReader.error);
fileReader.readAsDataURL(blob);
});
export const readAsText = (blob) =>
new Promise<string>((resolve, reject) => {
const fileReader = new FileReader();
fileReader.onload = () => resolve(fileReader.result as string);
fileReader.onerror = () => reject(fileReader.error);
fileReader.readAsText(blob);
});

View File

@@ -1,9 +1,9 @@
import { lowercaseExtension } from "@/base/file";
import log from "@/base/log";
import { type Electron } from "@/base/types/ipc";
import { FILE_TYPE } from "@/media/file-type";
import { decodeLivePhoto } from "@/media/live-photo";
import DownloadManager from "@/new/photos/services/download";
import { updateExifIfNeededAndPossible } from "@/new/photos/services/exif-update";
import {
EncryptedEnteFile,
EnteFile,
@@ -25,7 +25,6 @@ import type { User } from "@ente/shared/user/types";
import { downloadUsingAnchor } from "@ente/shared/utils";
import { t } from "i18next";
import { moveToHiddenCollection } from "services/collectionService";
import { updateFileCreationDateInEXIF } from "services/exif";
import {
deleteFromTrash,
trashFiles,
@@ -49,32 +48,8 @@ export enum FILE_OPS_TYPE {
DELETE_PERMANENTLY,
}
export async function getUpdatedEXIFFileForDownload(
fileReader: FileReader,
file: EnteFile,
fileStream: ReadableStream<Uint8Array>,
): Promise<ReadableStream<Uint8Array>> {
const extension = lowercaseExtension(file.metadata.title);
if (
file.metadata.fileType === FILE_TYPE.IMAGE &&
file.pubMagicMetadata?.data.editedTime &&
(extension == "jpeg" || extension == "jpg")
) {
const fileBlob = await new Response(fileStream).blob();
const updatedFileBlob = await updateFileCreationDateInEXIF(
fileReader,
fileBlob,
new Date(file.pubMagicMetadata.data.editedTime / 1000),
);
return updatedFileBlob.stream();
} else {
return fileStream;
}
}
export async function downloadFile(file: EnteFile) {
try {
const fileReader = new FileReader();
let fileBlob = await new Response(
await DownloadManager.getFile(file),
).blob();
@@ -98,11 +73,7 @@ export async function downloadFile(file: EnteFile) {
new File([fileBlob], file.metadata.title),
);
fileBlob = await new Response(
await getUpdatedEXIFFileForDownload(
fileReader,
file,
fileBlob.stream(),
),
await updateExifIfNeededAndPossible(file, fileBlob.stream()),
).blob();
fileBlob = new Blob([fileBlob], { type: fileType.mimeType });
const tempURL = URL.createObjectURL(fileBlob);
@@ -455,13 +426,12 @@ async function downloadFilesDesktop(
},
downloadPath: string,
) {
const fileReader = new FileReader();
for (const file of files) {
try {
if (progressBarUpdater?.isCancelled()) {
return;
}
await downloadFileDesktop(electron, fileReader, file, downloadPath);
await downloadFileDesktop(electron, file, downloadPath);
progressBarUpdater?.increaseSuccess();
} catch (e) {
log.error("download fail for file", e);
@@ -472,19 +442,13 @@ async function downloadFilesDesktop(
async function downloadFileDesktop(
electron: Electron,
fileReader: FileReader,
file: EnteFile,
downloadDir: string,
) {
const fs = electron.fs;
const stream = (await DownloadManager.getFile(
file,
)) as ReadableStream<Uint8Array>;
const updatedStream = await getUpdatedEXIFFileForDownload(
fileReader,
file,
stream,
);
const stream = await DownloadManager.getFile(file);
const updatedStream = await updateExifIfNeededAndPossible(file, stream);
if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) {
const fileBlob = await new Response(updatedStream).blob();

View File

@@ -192,7 +192,8 @@ For more details, see [translations.md](translations.md).
## Media
- [ExifReader](https://github.com/mattiasw/ExifReader) is used for Exif
parsing.
parsing. [piexifjs](https://github.com/hMatoba/piexifjs) is used for writing
back Exif (only supports JPEG).
- [jszip](https://github.com/Stuk/jszip) is used for reading zip files in the
web code (Live photos are zip files under the hood). Note that the desktop

View File

@@ -0,0 +1,163 @@
import { lowercaseExtension } from "@/base/file";
import log from "@/base/log";
import { FILE_TYPE } from "@/media/file-type";
import piexif from "piexifjs";
import type { EnteFile } from "../types/file";
/**
* Return a new stream after applying Exif updates if applicable to the given
* stream, otherwise return the original.
*
* This function is meant to provide a stream that can be used to download (or
* export) a file to the user's computer after applying any Exif updates to the
* original file's data.
*
* - This only updates JPEG files.
*
* - For JPEG files, the DateTimeOriginal Exif entry is updated to reflect the
* time that the user edited within Ente.
*
* @param enteFile The {@link EnteFile} whose data we want.
*
* @param stream A {@link ReadableStream} containing the original data for
* {@link enteFile}.
*
* @returns A new {@link ReadableStream} with updates if any updates were
* needed, otherwise return the original stream.
*/
export const updateExifIfNeededAndPossible = async (
enteFile: EnteFile,
stream: ReadableStream<Uint8Array>,
): Promise<ReadableStream<Uint8Array>> => {
// Not needed: Not an image.
if (enteFile.metadata.fileType != FILE_TYPE.IMAGE) return stream;
// Not needed: Time was not edited.
// TODO: Until the types reflect reality
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!enteFile.pubMagicMetadata?.data.editedTime) return stream;
const fileName = enteFile.metadata.title;
const extension = lowercaseExtension(fileName);
// Not possible: Not a JPEG (likely).
if (extension != "jpeg" && extension != "jpg") return stream;
const blob = await new Response(stream).blob();
try {
const updatedBlob = await setJPEGExifDateTimeOriginal(
blob,
new Date(enteFile.pubMagicMetadata.data.editedTime / 1000),
);
return updatedBlob.stream();
} catch (e) {
log.error(`Failed to modify Exif date for ${fileName}`, e);
// We used the file's extension to determine if this was a JPEG, but
// this is not a guarantee. Misnamed files, while rare, do exist. So in
// that is the error thrown by the underlying library, fallback to the
// original instead of causing the entire download or export to fail.
if (
e instanceof Error &&
e.message.endsWith("Given file is neither JPEG nor TIFF.")
) {
return blob.stream();
}
throw e;
}
};
/**
* Return a new blob with the "DateTimeOriginal" Exif tag set to the given
* {@link date}.
*
* @param jpegBlob A {@link Blob} containing JPEG data.
*
* @param date A {@link Date} to use as the value for the Exif
* "DateTimeOriginal" tag.
*
* @returns A new blob derived from {@link jpegBlob} but with the updated date.
*/
const setJPEGExifDateTimeOriginal = async (jpegBlob: Blob, date: Date) => {
let dataURL = await blobToDataURL(jpegBlob);
// Since we pass a Blob without an associated type, we get back a generic
// data URL of the form "data:application/octet-stream;base64,...".
//
// Modify it to have a `image/jpeg` MIME type.
dataURL = "data:image/jpeg;base64" + dataURL.slice(dataURL.indexOf(","));
const exifObj = piexif.load(dataURL);
if (!exifObj.Exif) exifObj.Exif = {};
exifObj.Exif[piexif.ExifIFD.DateTimeOriginal] =
convertToExifDateFormat(date);
const exifBytes = piexif.dump(exifObj);
const exifInsertedFile = piexif.insert(exifBytes, dataURL);
return dataURLToBlob(exifInsertedFile);
};
/**
* Convert a blob to a `data:` URL.
*/
const blobToDataURL = (blob: Blob) =>
new Promise<string>((resolve) => {
const reader = new FileReader();
// We need to cast to a string here. This should be safe since MDN says:
//
// > the result attribute contains the data as a data: URL representing
// > the file's data as a base64 encoded string.
// >
// > https://developer.mozilla.org/en-US/docs/Web/API/FileReader/readAsDataURL
reader.onload = () => resolve(reader.result as string);
reader.readAsDataURL(blob);
});
/**
* Convert a `data:` URL to a blob.
*
* Requires `connect-src data:` in the CSP (since it internally uses `fetch` to
* perform the conversion).
*/
const dataURLToBlob = (dataURI: string) =>
fetch(dataURI).then((res) => res.blob());
/**
* Convert the given {@link Date} to a format that is expected by Exif for the
* DateTimeOriginal tag.
*
* [Note: Exif dates]
*
* Common Exif date time tags, an in particular "DateTimeOriginal", are
* specified in the form:
*
* yyyy:MM:DD HH:mm:ss
*
* These values thus do not have an associated UTC offset or TZ. The common
* convention (based on my current understanding) is that these times are
* interpreted to be the local time where the photo was taken.
*
* Recently, there seems to be increasing support for the (newly standardized)
* "OffsetTimeOriginal" and related fields, which specifies time zone for
* "DateTimeOriginal" (and related fields).
*
* However, when the offset time tag is not present (a frequent occurrence, not
* just for older photos but also for screenshots generated by OSes as of 2024),
* we don't really know, and stick with the common convention:
*
* - When reading, assume that the Exif date is in the local TZ when deriving
* a UTC timestamp from it.
*
* - When writing, convert the UTC timestamp to local time.
*/
const convertToExifDateFormat = (date: Date) => {
// TODO: Exif - Handle offsettime if present
const yyyy = date.getFullYear();
const MM = zeroPad2(date.getMonth() + 1);
const dd = zeroPad2(date.getDate());
const HH = zeroPad2(date.getHours());
const mm = zeroPad2(date.getMinutes());
const ss = zeroPad2(date.getSeconds());
return `${yyyy}:${MM}:${dd} ${HH}:${mm}:${ss}`;
};
/** Zero pad the given number to 2 digits. */
const zeroPad2 = (n: number) => (n < 10 ? `0${n}` : `${n}`);

View File

@@ -116,6 +116,11 @@ export interface FileMagicMetadataProps {
export type FileMagicMetadata = MagicMetadataCore<FileMagicMetadataProps>;
export interface FilePublicMagicMetadataProps {
/**
* Modified value of the date time associated with an {@link EnteFile}.
*
* Epoch microseconds.
*/
editedTime?: number;
editedName?: string;
caption?: string;

View File

@@ -0,0 +1,42 @@
/**
* Types for [piexifjs](https://github.com/hMatoba/piexifjs).
*
* Non exhaustive, only the function we need.
*/
declare module "piexifjs" {
interface ExifObj {
Exif?: Record<number, unknown>;
}
interface Piexifjs {
/**
* Get exif data as object.
*
* @param jpegData a string that starts with "data:image/jpeg;base64,"
* (a data URL), "\xff\xd8", or "Exif".
*/
load: (jpegData: string) => ExifObj;
/**
* Get exif as string to insert into JPEG.
*
* @param exifObj An object obtained using {@link load}.
*/
dump: (exifObj: ExifObj) => string;
/**
* Insert exif into JPEG.
*
* If {@link jpegData} is a data URL, returns the modified JPEG as a
* data URL. Else if {@link jpegData} is binary as string, returns JPEG
* as binary as string.
*/
insert: (exifStr: string, jpegData: string) => string;
/**
* Keys for the tags in {@link ExifObj}.
*/
ExifIFD: {
DateTimeOriginal: number;
};
}
const piexifjs: Piexifjs;
export default piexifjs;
}